Как добавить систему аутентификации пользователей для django сайта используя allauth и реакт

Часы
31.01.2025
Глазик
29
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0

Введение

Да, давно я не писал про этот сайт. И теперь считаю своим долгом закончить его во чтобы-то ни стало. Итак, нужно разработать и добавить систему аутентификации и регистрации пользователей на сайт. Как это сделать и как это будет выглядеть?
Здесь на самом деле нет ничего сложного. Сам подумай, нам необходимо разработать следующие элементы:
  1. Форму входа
  2. Форму регистрации
  3. Форму изменения пароля
  4. Форму выхода
  5. Форму подтверждения почты + шаблон
  6. Форму сброса пароля + шаблон
  7. Модальное окно для профиля пользователя
И, собственно говоря, всё ... Хотя AllAuth необязательно интегрировать на сайт, можно реализовать регистрацию и управление пользователями самостоятельно, это всё таки будет проще. Нам останется реализовать только фронтенд часть сайта.
Я не очень сильно хочу усложнять и перегружать данную статью имплементацией JWT-токенов или реализации таких архитектур управления учётными профилями пользователей, как SAML2 или LDAP, или даже регистрацию через социальные сети. Это будет в следующих проектах и статьях, ибо она и так получилась слишком большой.
То есть в этой статье я опишу процесс интеграции AllAuth библиотеки на django сайт с минимально необходимым функционалом.

Пишем фронтенд для формы входа и регистрации

Для начала подготовим контейнеры в которых будем рисовать наши модальные окна. Добавь следующие строчки кода в шаблон base.html, сразу перед тегом "main" и после конца блока с шапкой сайта:
...
{% block header %}
{% endblock %}
<div data-type="profile"><a href="#">profile</a></div>
...
<div id="auth-modal"></div>
<main class="flex-auto pl-2 pr-2 pt-3 pb-3">
{% block main %}
{% endblock %}
</main>
В моём случае тег с атрибутом data-type="profile" будет играть роль ещё одной кнопки в шапке сайта. Она будет открывать карточку пользователя. Карточка пользователя будет отрисовываться в виде модального окна, которое будет находиться в теге с id="auth-modal".
Теперь подключим новый компонент Auth. Этот реакт-компонент и будет рисовать для нас модальные окна Входа, Регистрации, карточку пользователя и прочие. В файле компонента src/index.js, сделаем импорт:
import Header from './components/Header';
import Footer from './components/Footer';
import AppSettings from './components/AppSettings';
import AppUtils from './components/AppUtils';
import AppActions from './components/AppActions';
import AppQueries from './components/AppQueries';
import Msg from './components/Msg';
import Hint from './components/Tutorial';
import Auth from './components/Auth';
Сам файл компонента реализует компонент под названием Auth, который через пропсы определяет какое модальное окно показать и как обратиться к серверу, соответственно. Наверняка, есть более простой и элегантный способ сделать всё то, что я сейчас сделал, но мне просто нужны точки общения с сервером откуда я смогу получать шаблоны для входа и регистрации и менять сайт соответственно. Вот шаблонный файл src/components/Auth.js:
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import Modal from '@mui/material/Modal';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button'
import axios from "axios";
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Input from '@mui/material/Input';
import InputLabel from '@mui/material/InputLabel';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import Checkbox from '@mui/material/Checkbox';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import { Wait, StopWait, Msg } from './Waiter';

function getCookie (name) {
let cookieValue = null
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim()
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1))
break
}
}
}
return cookieValue
}
export function getCSRFToken () {
return getCookie('csrftoken')
}
const BASE_URL = `/_allauth/browser/v1`
export const URLs = Object.freeze({
// Meta
CONFIG: BASE_URL + '/config',
// Account management
CHANGE_PASSWORD: BASE_URL + '/account/password/change',
EMAIL: BASE_URL + '/account/email',
PROVIDERS: BASE_URL + '/account/providers',
// Account management: 2FA
AUTHENTICATORS: BASE_URL + '/account/authenticators',
RECOVERY_CODES: BASE_URL + '/account/authenticators/recovery-codes',
TOTP_AUTHENTICATOR: BASE_URL + '/account/authenticators/totp',
// Auth: Basics
LOGIN: BASE_URL + '/auth/login',
LOGOUT: BASE_URL + '/auth/session',
REQUEST_LOGIN_CODE: BASE_URL + '/auth/code/request',
CONFIRM_LOGIN_CODE: BASE_URL + '/auth/code/confirm',
SESSION: BASE_URL + '/auth/session',
REAUTHENTICATE: BASE_URL + '/auth/reauthenticate',
REQUEST_PASSWORD_RESET: BASE_URL + '/auth/password/request',
RESET_PASSWORD: BASE_URL + '/auth/password/reset',
SIGNUP: BASE_URL + '/auth/signup',
VERIFY_EMAIL: BASE_URL + '/auth/email/verify',
// Auth: 2FA
MFA_AUTHENTICATE: BASE_URL + '/auth/2fa/authenticate',
MFA_REAUTHENTICATE: BASE_URL + '/auth/2fa/reauthenticate',
// Auth: Social
PROVIDER_SIGNUP: BASE_URL + '/auth/provider/signup',
REDIRECT_TO_PROVIDER: BASE_URL + '/auth/provider/redirect',
PROVIDER_TOKEN: BASE_URL + '/auth/provider/token',
// Auth: Sessions
SESSIONS: BASE_URL + '/auth/sessions',
// Auth: WebAuthn
REAUTHENTICATE_WEBAUTHN: BASE_URL + '/auth/webauthn/reauthenticate',
AUTHENTICATE_WEBAUTHN: BASE_URL + '/auth/webauthn/authenticate',
LOGIN_WEBAUTHN: BASE_URL + '/auth/webauthn/login',
SIGNUP_WEBAUTHN: BASE_URL + '/auth/webauthn/signup',
WEBAUTHN_AUTHENTICATOR: BASE_URL + '/account/authenticators/webauthn'
})

var session = {
username: null,
email: {
address: null,
verified: null,
},
is_authenticated: false
}

const updateEmail = async () =>{
const {data} = await axios.get(URLs.EMAIL, {
headers: {
"accept": "application/json",
'Content-Type': "application/json",
}
}).catch(error=>{
return error.response
})
if (data.status === 200){
session.email.address = data.data[0].email
if (data.data[0].verified){
session.email.verified = 'verified'
}else{
session.email.verified = 'not verified'
}
}else if(data.status === 401){
session.email.address = null
session.email.verified = null
}
}

const updateSession = async () => {
const {data} = await axios.get(URLs.SESSION, {
headers: {
"accept": "application/json",
'Content-Type': "application/json",
}
}).catch(error=>{
return error.response
})

if (data.status === 200){
session.username = data.data.user.username
session.is_authenticated = true
}else if(data.status === 401){
session.username = null
session.is_authenticated = false
}
updateEmail()
}

function onRequestConfirmEmail(req, email){
}

function onRequestPasswordReset(req){
}

function fetchPasswordReset(req){
}

function onPasswordChange(req){
}

function fetchPasswordChange(req){
}

function fetchLogin(req){
}

function onLogin(req){
}

function fetchSignup(req){
}

function onSignup(req){
}

function onSignout(req){
}

function fetchProfile(req){
}

export default function Auth(props){

const openModal = (event) =>{
setModal(true);
}

const [isModal, setModal] = React.useState(false);
const modalRef = React.useRef(null)
const req = {'setModal': setModal, 'modal': modalRef}
const onInitBtn = document.getElementById(props.btnId)
onInitBtn.addEventListener(props.btnId, openModal)
return (
<Modal
ref={modalRef}
open={isModal}
onClose={()=>{setModal(false)}}
>
<Box id='auth-modal' className="absolute top-1/2 left-1/2 -translate-x-2/4 -translate-y-2/4 shadow-md p-4 bg-white">
<div className="flex flex-col gap-3 min-w-72 ">
<div id="modal-title">
<Typography variant="h6" component="h3">
{props.title}
</Typography>
</div>
<hr></hr>
<div id="modal-content">
{props.fetchFunc(req)}
</div>
<hr></hr>
<div id="modal-button">
{props.procedBtnFunc !== null &&
<Box className="flex flex-row gap-4 items-center justify-between">
<Button onClick={()=>{props.procedBtnFunc(req)}} variant='text'>proceed</Button>
</Box>
}
</div>
</div>
</Box>
</Modal>
)
}

const authentication_profile_container = document.getElementById('auth-modal');
if (authentication_profile_container){
const authentication_profile_root = createRoot(authentication_profile_container);
authentication_profile_root.render(<Auth title='Profile' btnId='onProfile' procedBtnFunc={null} fetchFunc={fetchProfile}></Auth>);
}
Теперь не много о структуре и работе данного модуля. Есть главный экспортируемый модуль - Auth. Это собственно, модальное окно, которое будет содержать, и карточку пользователя, и форму входа, и формы регистрации, и прочие формы.
Ещё есть два вида функций on* и fetch*. Первые общаются с сервером, вторые обновляют модальное окно. Не обязательно следовать данному принципу, но так легче поддерживать код.
Дальше есть функции updateEmail и updateSession. Они занимаются ровно тем, чем они обзываются, обновляют информацию об адресе почты и обновляют информацию о текущей сессии. Так же добавил такие дополнительные функции как:
  1. getCookie(name) - на случай если захочу получить что-нибудь и куки браузера.
  2. getCSRFToken() - почти каждый запрос требует csrf-токен поэтому написал отдельную функцию
На счёт, глобального объекта URLs. Это не весь список доступных путей к allauth api, но минимально необходимый для нас. Можно конечно и удалить его, и прописывать все URL вручную, но это уже без меня. Двигаемся дальше.
Надо не забыть поменять компонент Header в src/components/header.js. При создании кнопок в шапке сайта я создаю кастомные события, к которым подключу модальное окно, вот так:
export default function Header() {
const header_buttons = document.getElementById('meta-header')
const btns = []
for ( const btn of header_buttons.children){
const ref = btn.firstElementChild.getAttribute('href')
if (btn.dataset.type == 'inner-link'){
btns.push(<Button color='primary'><a href={ref}>{btn.innerText}</a><LinkIcon className='mb-2' fontSize='small' /></Button>)
}
else if (btn.dataset.type == 'tutorial' ){
btns.push(<Button id='onTutorial' onClick={(e)=>{
const onTutorialEvent = new Event('onTutorial')
e.currentTarget.dispatchEvent(onTutorialEvent)
}} color='primary'><a href={ref}>{btn.innerText}</a><QuestionMarkIcon className='mb-2' fontSize='small'/></Button>)
}
else if (btn.dataset.type == 'profile' ){
btns.push(
<Button id='onProfile' onClick={(e)=>{
const onProfileEvent = new Event('onProfile')
e.currentTarget.dispatchEvent(onProfileEvent)
}} color='primary'><a href="#">{btn.innerText}</a><AccountBoxIcon className='mb-2' fontSize='small'/></Button>
)
}
}
Поменяй всё что у тебя было на выделенные строчки

Подключаем Allauth к приложению

Для начала хотел бы отметить официальную документацию, она полная и вполне самодостаточная, хотя, чтобы разобраться в том как использовать данную библиотеку конкретно на своём сайте потребуется некоторое время и изучение официальных примеров. Слава им, они у них есть.
Как всегда, начнём с установки необходимых пакетов. Пакет django-allauth обязателен, но django-allauth[socialaccount] нет, если не нужно использовать соцсети в качестве провайдера для входа. Но я конечно же установлю их оба:
pip install django-allauth
Дальше необходимо добавить следующий allauth-бэкенд в settings.py:
AUTHENTICATION_BACKENDS = [
# Needed to login by username in Django admin, regardless of `allauth`
'django.contrib.auth.backends.ModelBackend',
# `allauth` specific authentication methods, such as login by email
'allauth.account.auth_backends.AuthenticationBackend',
]
Allauth разработан как встраиваемое django-приложение, вернее целая куча приложений, которые необходимо подключить:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'rest_framework',
'Backend.apps.BackendConfig',
'Frontend.apps.FrontendConfig',
'Authentication.apps.AuthenticationConfig',
# BY ALLAUTH
'allauth',
'allauth.account',
]

MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",

# BY ALLAUTH
"allauth.account.middleware.AccountMiddleware",
)
Также будет необходимо подключить и настроить email-backend. С той лишь целью, чтобы протестировать подтверждение отправленной почты и возможность сбросить пароль. Я подключу тестовый бэкенд, ибо это просто быстрее и нагляднее. Все сообщения будут записываться в файлы. В дальнейшем мы конечно же поменяем его на нормальный email-бэкенд.
# BY ALLAUTH testing only
EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
EMAIL_FILE_PATH = BASE_DIR / 'emails'
Добавь эти строчки где-нибудь в settings.py
В файле settings.py мы закончили. Осталось только добавить пути и провести миграцию:
from django.contrib import admin
from django.urls import path, include
from rest_framework import routers
from Backend import views

router = routers.DefaultRouter()
router.register(r'results', views.BackendModelView, 'result')

urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(router.urls)),
path('', include('Frontend.urls')),
path('', include('Authentication.urls')),
# BY ALLAUTH
path('accounts/', include('allauth.urls')),
]
В файле Website/urls.py
python manage.py migrate
Создаём миграцию
После всех этих манипуляций, перейди по адресу http://localhost:8000/accounts/, ты должен увидеть что-то вроде этого:
пример успешно подключённого приложения allauth

Переводим Allauth в "headless" режим

По факту, всё готово, но не совсем. Мне не нужно перенаправляться на отдельную страницу (это не то, как мой django-react-сайт работает). В идеале мне нужен лишь api, а дизайн я уж и сам сделаю. И чтобы получить доступ к api allauth, потребуется ещё кое-что докрутить. Опять же, официальна документация вполне самодостаточна насчёт этого вопроса.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'rest_framework',
'Backend.apps.BackendConfig',
'Frontend.apps.FrontendConfig',
'Authentication.apps.AuthenticationConfig',
# BY ALLAUTH
'allauth',
'allauth.account',
'allauth.headless',
'allauth.mfa',
'allauth.usersessions',
]
Добавь следующие приложения
И ещё нужно добавить новые пути к api allauth
from django.contrib import admin
from django.urls import path, include
from rest_framework import routers
from Backend import views

router = routers.DefaultRouter()
router.register(r'results', views.BackendModelView, 'result')

urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(router.urls)),
path('', include('Frontend.urls')),
path('', include('Authentication.urls')),
# BY ALLAUTH
path('accounts/', include('allauth.urls')),
path("_allauth/", include("allauth.headless.urls")),
]
В файле Website/urls.py
Это делать необязательно, но всё же. Раз уж весь фронтенд на реакте, то и лишние страницы мне тоже не нужны, а значит мой сайт будет использовать allauth в режиме "headless only". Для этого добавь данные строчки, где-нибудь в settings.py:
HEADLESS_ONLY = True
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": "http://localhost:8000/email-verify/{key}",
"account_reset_password_from_key": "http://localhost:8000/password-reset/{key}",
}
Как ты мог заметить мы добавили ещё словарь HEADLESS_FRONTEND_URLS. Он будет необходим когда будем реализовывать подтверждение почты или сброса пароля, а пока не беспокойся из-за этого.

Связываем всё вместе

С этого момента мы можем поступить следующим образом; В тупую написать POST запросы к API allauth прямо из реакта (нам нужно буквально 4 таких запроса: на вход /auth/login, на регистрацию /auth/signup, на подтверждение почты /auth/email/verify и на восстановление пароля /auth/password/request). При этом, никакое django-приложение не потребуется.
Кстати, весь headless API для выполнения запросов можно посмотреть тут, !!крайне рекомендую!!.
Но можно поступить по другому; Написать отдельное приложение, собрать там необходимые ModelForm-ы отрендерить их на сервере и при необходимости возвращать клиенту. Это куда дольше, но позволит тебе и мне лучше понять внутреннюю работу allauth пакета и более того, такой подход более гибкий в плане дизайна и отказоустойчивости приложения.
Мы пойдём по первому пути ибо Реакт ... >﹏<

Пишем запросы на сервер (Фронтенд)

Делаем карточку пользователя

Начнём мы с карточки пользователя. Конечно она будет не слишком большой и сложной, скорее даже наоборот. Минимум функционала, только необходимые данные. Пока что без привязки к другим соц. сетям.
function fetchProfile(req){
updateSession()

return (
<div>
{ session.is_authenticated ?
<Box>
<Typography>
You are logged in as <b>{session.username}</b>
</Typography>
<Box className="flex flex-row gap-4 items-center">
<Button onClick={()=>{fetchPasswordChange(req)}} variant='text'>Change password</Button>
<Button onClick={()=>{fetchPasswordReset(req)}} variant='text'>Reset password</Button>
</Box>
<Typography>
Your email are: <b>{session.email.address}</b> | {session.email.verified}
</Typography>
{ session.email.verified === 'not verified' &&
<Box className="flex flex-row gap-4 items-center">
<Button onClick={()=>{onRequestConfirmEmail(req, session.email.address)}} variant='text'>verify</Button>
</Box>
}
<Box className="flex flex-row gap-4 items-center">
<Button onClick={()=>{onSignout(req)}} variant='text'>Sign out</Button>
</Box>
</Box>
:
<Box>
<Typography>
You are not logged in
</Typography>
<Box className="flex flex-row gap-4 items-center">
<Button onClick={()=>{fetchLogin(req)}} variant='text'>Log in</Button>
<Typography>or</Typography>
<Button onClick={()=>{fetchSignup(req)}} variant='text'>Sign up</Button>
</Box>
</Box>
}
</div>
)
}
Сначала мы получаем статус сессии, то есть узнаём гость это или уже зарегистрированный пользователь. После, если это зарегистрированный пользователь, отображаем всю инфу которая может ему пригодиться (почта, тип почты, имя). И конечно же добавляем кнопки для взаимодействия.
Как-то так, зарегистрированный пользователь
Как-то так, не зарегистрированный пользователь
К каждой кнопке была привязана функция обновления модального окна. И дальше мы будем разбирать, то как это окно меняется и как будет отправляться запрос на сервер.

Форма и запрос на регистрацию

Для регистрации пользователя я использую 4 поля (одно из которых опционально) (+1 одно поле скрыто, это поле мы заполняем csrf-токеном). Так же меняю заголовок и основную кнопку модального окна.
function fetchSignup(req){
var container = req.modal.current.querySelector('#modal-content')
createRoot(container).render(
<form method='post' class="flex flex-col gap-5" style={{maxHeight: '500px', overflow: 'scroll'}}>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="username">Your username</InputLabel>
<Input class="w-fit" id="username"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="email">Your email(optional)</InputLabel>
<Input type="email" id="email"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="password">Your password</InputLabel>
<Input type='password' id="password"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="password2">Your password(again)</InputLabel>
<Input type='password' id="password2"/>
</FormControl>
<List style={{maxWidth: '300px'}}>
<ListItem>
<ListItemText style={{color: "#6c6c24"}} primary="* Your password can’t be too similar to your other personal information." />
</ListItem>
<ListItem>
<ListItemText style={{color: "#6c6c24"}} primary="* Your password must contain at least 8 characters." />
</ListItem>
<ListItem>
<ListItemText style={{color: "#6c6c24"}} primary="* Your password can’t be a commonly used password." />
</ListItem>
<ListItem>
<ListItemText style={{color: "#6c6c24"}} primary="* Your password can’t be entirely numeric." />
</ListItem>
</List>
</form>
)
var container_btn = req.modal.current.querySelector('#modal-button')
createRoot(container_btn).render(
<Box className="flex flex-row gap-4 items-center justify-between">
<Button onClick={()=>{onSignup(req)}} variant='text'>proceed</Button>
</Box>
)
var container_title = req.modal.current.querySelector('#modal-title')
createRoot(container_title).render(
<Typography variant="h6" component="h3">
Sign up
</Typography>
)
}
При нажатии кнопки PROCEED, отправляется форма. Ну это если кратко, а если развёрнуто то ... Сначала открывается спинер, после собираем все необходимые данные с формы, проверяем их. Делаем POST-запрос не сервер по пути /_allauth/browser/v1/auth/signup т. е. URLs.SIGNUP, предварительно оставив в заголовке запроса csrf-токен и требуемый формат ответа. Дальше, вне зависимости от ответа, мы закрываем форму и оставляем сообщение пользователю об успехе запроса.
function onSignup(req){
// Do POST request to server
// Try to sign up
Wait()
var form_data = new FormData()
var password1 = document.querySelector('#password').value
var password2 = document.querySelector('#password2').value
form_data.append('username', document.querySelector('#username').value)
form_data.append('email', document.querySelector('#email').value)
form_data.append('password', password1)
form_data.append('csrfmiddlewaretoken', getCSRFToken())
if (password2 !== password1) {
Msg('Password does not match.', 'error')
return
}
axios.post(URLs.SIGNUP, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
})
.then(data => {
StopWait(`Welcome ${document.querySelector('#username').value}.`, 'success')
updateSession()
req.setModal(false);
})
.catch(err => {
StopWait(`${err.response.data.errors[0].message}`, 'error')
req.setModal(false);
});
}

Форма и запрос на вход

Форма входа, проще чем регистрация. Тут требуется только имя пользователя и его пароль.
Мы создаём три корневых элемента и меняем соответственно саму форму, кнопку и заголовок формы.
function fetchLogin(req){
var container = req.modal.current.querySelector('#modal-content')
createRoot(container).render(
<form method='post' class="flex flex-col gap-5">
<FormControl>
<InputLabel htmlFor="username">Your username</InputLabel>
<Input id="username"/>
</FormControl>
<FormControl>
<InputLabel htmlFor="password">Your password</InputLabel>
<Input type='password' id="password"/>
</FormControl>
<Link href="#" onClick={()=>{fetchPasswordReset(req)}}><Typography>Forgot your password?</Typography></Link>
<FormControl>
<FormControlLabel control={<Checkbox id="remember-me"/>} label="Remember me?" />
</FormControl>
</form>
)
var container_btn = req.modal.current.querySelector('#modal-button')
createRoot(container_btn).render(
<Box className="flex flex-row gap-4 items-center justify-between">
<Button onClick={()=>{onLogin(req)}} variant='text'>proceed</Button>
</Box>
)
var container_title = req.modal.current.querySelector('#modal-title')
createRoot(container_title).render(
<Typography variant="h6" component="h3">
Log in
</Typography>
)
}
Отправляем форму по адресу /_allauth/browser/v1/auth/login т.е. URLs.LOGIN.
function onLogin(req){
Wait()
var form_data = new FormData()
form_data.append('username', document.querySelector('#username').value)
form_data.append('password', document.querySelector('#password').value)
form_data.append('remember', document.querySelector('#remember-me').checked)
form_data.append('csrfmiddlewaretoken', getCSRFToken())
axios.post(URLs.LOGIN, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
})
.then(data => {
StopWait(`Welcome ${document.querySelector('#username').value}.`, 'success')
updateSession()
req.setModal(false);
})
.catch(err => {
StopWait(`${err.response.data.errors[0].message}`, 'error')
req.setModal(false);
});
}

Форма и запрос на изменение пароля

Для измения пароля нам потребуется отдельная форма. В функции fetchPasswordChange, мы меняем форму, заголовок и кнопку подтверждения. Так же, я запрашиваю ввести новый пароль дважды, ну на всякий случай...
function fetchPasswordChange(req){
var container = req.modal.current.querySelector('#modal-content')
createRoot(container).render(
<form method='post' class="flex flex-col gap-5">
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="current_password">Current password</InputLabel>
<Input type="password" id="current_password"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="new_password1">New password</InputLabel>
<Input type="password" id="new_password1"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="new_password2">New password(again)</InputLabel>
<Input type="password" id="new_password2"/>
</FormControl>
</form>
)
var container_btn = req.modal.current.querySelector('#modal-button')
createRoot(container_btn).render(
<Box className="flex flex-row gap-4 items-center justify-between">
<Button onClick={()=>{onPasswordChange(req)}} variant='text'>change</Button>
</Box>
)
var container_title = req.modal.current.querySelector('#modal-title')
createRoot(container_title).render(
<Typography variant="h6" component="h3">
Change password
</Typography>
)
}
Перед тем как отправить запрос на изменение пароля, нужно проверить совпадают ли новые пароли друг с другом. После чего делаем POST-запрос по адресу /_allauth/browser/v1/account/password/change или URLs.CHANGE_PASSWORD.
function onPasswordChange(req){
var form_data = new FormData()
var password1 = document.querySelector('#new_password1').value
var password2 = document.querySelector('#new_password2').value
form_data.append('current_password', document.querySelector('#current_password').value)
form_data.append('new_password', password1)
form_data.append('csrfmiddlewaretoken', getCSRFToken())
if (password2 !== password1) {
Msg('Password does not match.', 'error')
return
}
axios.post(URLs.CHANGE_PASSWORD, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
})
.then(data => {
StopWait("You had successfully change a password", 'success')
updateSession()
req.setModal(false);
})
.catch(err => {
StopWait(`${err.response.data.errors[0].message}`, 'error')
req.setModal(false);
});
}

Форма и запрос на сброс пароля

Обновляем модальное окно т.е. заголовок, форму и саму кнопку конечно же. Для отправки запроса на востановление пароля потребуется только адресс электронной почты.
function fetchPasswordReset(req){
var container = req.modal.current.querySelector('#modal-content')
createRoot(container).render(
<form method='post' class="flex flex-col gap-5">
<Typography> You will recieve a confirmation message.</Typography>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="password_email">Your email</InputLabel>
<Input type="email" id="password_email"/>
</FormControl>
</form>
)
var container_btn = req.modal.current.querySelector('#modal-button')
createRoot(container_btn).render(
<Box className="flex flex-row gap-4 items-center justify-between">
<Button onClick={()=>{onRequestPasswordReset(req)}} variant='text'>send</Button>
</Box>
)
var container_title = req.modal.current.querySelector('#modal-title')
createRoot(container_title).render(
<Typography variant="h6" component="h3">
Password reset
</Typography>
)
}
Итак как это вообще работает? Пользователь забыл пароль и хочет его востановить. Для этого он отравляет POST-запрос по адресу /_allauth/browser/v1/auth/password/request или URLs.REQUEST_PASSWORD_RESET. Этот запрос отправляется на сервер, откуда будет отправленно электронное письмо на указаный адрес.
function onRequestPasswordReset(req){
Wait()
var form_data = new FormData()
var email = document.querySelector('#password_email').value
form_data.append('email', email)
axios.post(URLs.REQUEST_PASSWORD_RESET, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then((data)=>{
StopWait(`Successfully send an email to ${email}`, 'success')
req.setModal(false);
}).catch((err)=>{
StopWait(`${err.response.data.errors[0].message}`, 'error')
req.setModal(false);
})


}

Функция запроса на востановление пароля
Письмо отправилось. В письме будет ссылка на страницу с ключом в адресе. Нужно перейти по данному адресу чтобы попасть на такую вот страницу.
Из коробки django-allauth не предоставляет страницу для обработки запростов по востановлению пароля или верификации почты (ЕСЛИ КОНЕЧНО ЭТО НЕ HEADLESS РЕЖИМ). В противном случае будет перенаправление на шаблонную страницу предоставляемую при обычном режиме.
На этой странице нужно заполнить данную форму. То есть ввести новый пароль. Было бы наверное лучше если бы я ещё запрашивал подтверждение пароля, но сойдёт и так. В действительности форма должна быть отправленна с двумя полями:
  1. новый пароль
  2. сгенерированный ключ
function onConfirmReset(key, container){
var form_data = new FormData()
var password1 = document.querySelector('#password1').value
var password2 = document.querySelector('#password2').value
if (password2 !== password1) {
Msg('Password does not match.', 'error')
return
}
form_data.append('key', key)
form_data.append('password', password1)
axios.post(URLs.RESET_PASSWORD, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then((data) => {
Msg('You have successfully changed your password.', 'success')
createRoot(container.current).render(
<Typography>You have successfully changed your password.</Typography>
)
}).catch((err) => {
if (err.response.status === 401){
Msg('You have successfully changed your password.', 'success')
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>You have successfully changed your password.</Typography>
</Box>
)
}else{
var errors = err.response.data.errors
const lines = []
errors.forEach((error) =>{
lines.push(<Typography>{error.message}</Typography>)
})
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
{lines}
</Box>
)
}
});
}
В данном случае я не делал разделение на on* и fetch* функции ибо это отдельный компонент и компонент довольно маленький. Помести данную функцию в ./components/PasswordReset.js
Для того чтобы данная функция вообще имела смысл я создал отдельный компонент под названием PasswordReset. Создал файл-компонент PasswordReset.js в components и подключил его в index.js:
import Header from './components/Header';
import Footer from './components/Footer';
import AppSettings from './components/AppSettings';
import AppUtils from './components/AppUtils';
import AppActions from './components/AppActions';
import AppQueries from './components/AppQueries';
import Message from './components/Msg';
import Hint from './components/Tutorial';
import Auth from './components/Auth';
import PasswordReset from './components/PasswordReset';
Вот полный код компонента:
import * as React from 'react';
import axios from "axios";
import { createRoot } from 'react-dom/client';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button'
import {URLs, getCSRFToken} from './Auth'
import FormControl from '@mui/material/FormControl';
import Input from '@mui/material/Input';
import InputLabel from '@mui/material/InputLabel';
import { Wait, StopWait, Msg } from './Waiter';


function onConfirmReset(key, container){
var form_data = new FormData()
var password1 = document.querySelector('#password1').value
var password2 = document.querySelector('#password2').value
if (password2 !== password1) {
Msg('Password does not match.', 'error')
return
}
form_data.append('key', key)
form_data.append('password', password1)
axios.post(URLs.RESET_PASSWORD, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then((data) => {
Msg('You have successfully changed your password.', 'success')
createRoot(container.current).render(
<Typography>You have successfully changed your password.</Typography>
)
}).catch((err) => {
if (err.response.status === 401){
Msg('You have successfully changed your password.', 'success')
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>You have successfully changed your password.</Typography>
</Box>
)
}else{
var errors = err.response.data.errors
const lines = []
errors.forEach((error) =>{
lines.push(<Typography>{error.message}</Typography>)
})
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
{lines}
</Box>
)
}
});
}

export default function PasswordReset(){
const key = document.querySelector('#password-reset-block').dataset.key
const containerRef = React.useRef(null)
return(
<Box ref={containerRef} className="w-full h-full content-center">
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>To finish password reset procedure fill the form bellow</Typography>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="password1">Your new password</InputLabel>
<Input type='password' id="password1"/>
</FormControl>
<FormControl style={{maxWidth: 'fit-content'}}>
<InputLabel htmlFor="password2">Your new password(again)</InputLabel>
<Input type='password' id="password2"/>
</FormControl>
<Button onClick={()=>{onConfirmReset(key, containerRef)}} variant='text'>proceed</Button>
</Box>
</Box>
)
}

const password_reset_container = document.getElementById('password-reset-block')
if (password_reset_container){
const password_reset_root = createRoot(password_reset_container);
password_reset_root.render(<PasswordReset></PasswordReset>);
}
Это ещё не всё. Потребуется ещё создание html-шаблона на стороне сервера и добавление соответствующих путей в urls.py

Форма и запрос на подтверждение почты

Так же моя система аутентификации поддерживает проверку почты на действительность. Пока смысла в этом особо нет, но в будущем это будет вполне полезно если я, например, хочу знать действительный ли это адресс электронной почты или нет.
Мы должны отправить PUT-запрос по адресу /_allauth/browser/v1/account/email или URLs.EMAIL.
function onRequestConfirmEmail(req, email){
Wait()
var form_data = new FormData()
form_data.append('email', email)
axios.put(URLs.EMAIL, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then( data =>{
StopWait(`Successfully send an email to ${email}`, 'success')
}).catch( err =>{
if (err.response.status !== 403){
StopWait(`${err.response.data.errors[0].message}`, 'error')
}
})
}
На указанную почту придёт письмо с сылкой на страницу с ключом. Перейдя на данную страницу пользователь увидит следующее:
Где нужно только нажать на кнопку. Оптравкой формы подтверждения занимается вот эта функция:
function onConfirmEmail(key, container){
var form_data = new FormData()
form_data.append('key', key)
axios.post(URLs.VERIFY_EMAIL, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then((data) => {
createRoot(container.current).render(
<Typography>You successfully verified your email.</Typography>
)
}).catch((err) => {
if (err.response.status === 401){
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>You successfully verified your email.</Typography>
</Box>
)
}
var errors = err.response.data.errors
const lines = []
errors.forEach((error) =>{
lines.push(<Typography>{error.message}</Typography>)
})
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
{lines}
</Box>
)
});
}
В данном случае я не делал разделение на on* и fetch* функции ибо это отдельный компонент и компонент довольно маленький. Помести данную функцию в ./components/EmailVerify.js
Она отправляет ключ на сервер для подтверждения почты и возвращает соответствующее сообщение о статусе верификации.
Ровно как и в случае со сбросом пароля, я создал отдельный компонент и подключил его в index.js:
import Header from './components/Header';
import Footer from './components/Footer';
import AppSettings from './components/AppSettings';
import AppUtils from './components/AppUtils';
import AppActions from './components/AppActions';
import AppQueries from './components/AppQueries';
import Message from './components/Msg';
import Hint from './components/Tutorial';
import Auth from './components/Auth';
import EmailVerify from './components/EmailVerify';
import PasswordReset from './components/PasswordReset';
А вот и полный код компонента EmailVerify:
import * as React from 'react';
import axios from "axios";
import { createRoot } from 'react-dom/client';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button'
import {URLs, getCSRFToken} from './Auth'
import { Wait, StopWait, Msg } from './Waiter';


function onConfirmEmail(key, container){
var form_data = new FormData()
form_data.append('key', key)
axios.post(URLs.VERIFY_EMAIL, form_data, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).then((data) => {
createRoot(container.current).render(
<Typography>You successfully verified your email.</Typography>
)
}).catch((err) => {
if (err.response.status === 401){
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>You successfully verified your email.</Typography>
</Box>
)
}
var errors = err.response.data.errors
const lines = []
errors.forEach((error) =>{
lines.push(<Typography>{error.message}</Typography>)
})
createRoot(container.current).render(
<Box className="flex flex-col gap-6 items-center justify-between">
{lines}
</Box>
)
});
}

export default function EmailVerify(){
const key = document.querySelector('#email-verify-block').dataset.key
const containerRef = React.useRef(null)
return(
<Box ref={containerRef} className="w-full h-full content-center">
<Box className="flex flex-col gap-6 items-center justify-between">
<Typography>To finish email verification click the button below</Typography>
<Button onClick={()=>{onConfirmEmail(key, containerRef)}} variant='text'>proceed</Button>
</Box>
</Box>
)
}

const email_verify_container = document.getElementById('email-verify-block')
if (email_verify_container){
const email_verify_root = createRoot(email_verify_container);
email_verify_root.render(<EmailVerify></EmailVerify>);
}
Это ещё не всё. Потребуется ещё создание html-шаблона на стороне сервера и добавление соответствующих путей в urls.py

Форма и запрос на выход

И наконец, выход из текущей сессии. Делается DELETE запрос по адресу /_allauth/browser/v1/auth/session или URLs.LOGOUT. Будет возвращена ошибка с кодом 401, поэтому выход делаем в catch блоке.
function onSignout(req){
Wait()
axios.delete(URLs.LOGOUT, {
headers: {
"accept": "application/json",
"X-CSRFToken": getCSRFToken(),
'Content-Type': "application/json",
}
}).catch(error =>{
StopWait('You have beed log out.')
updateSession()
req.setModal(false)
})
}

Настраиваем маршруты и представления для подтверждения почты и сброса пароля

Мы почти закончили. Осталось только настроить Бэкенд для подтверждения почты и изменения пароля. Создай новое django-приложение, если не знаешь как, смотри тут. Назови его Authentication ну и конечно подключи его к проекту сайта.
Дальше создай два шаблона в templates/Authentication, email_verify.html и password_reset.html
Содержание email_verify.html:
{% extends "Frontend/base.html" %}

{% block main %}
<div id="email-verify-block" class="w-full h-full" data-key="{{key}}">
</div>
{% endblock%}
Содержание password_reset.html:
{% extends "Frontend/base.html" %}

{% block main %}
<div id="password-reset-block" class="w-full h-full" data-key="{{key}}">
</div>
{% endblock%}
Как видишь, они почти идентичны. Впринципе, мог и один общий шаблон сделать, но подумал что возможно в будущем может потребоваться их как-то развивать по отдельности. Теперь добавим два маршрута в Authentication/urls.py:
from django.urls import path
from .views import email_verify, password_reset

urlpatterns = [
path('email-verify/<str:key>', email_verify, name='email_verify'),
path('password-reset/<str:key>', password_reset, name='password_reset'),
]
Заметь пути совпадают с теми что я указывал в settings.py ранее. Осталось только создать соответствующие представления для данных шаблонов, email_verify и password_reset:
from django.shortcuts import render

def email_verify(request, key):
context = {
'key': key,
}
return render(request, 'Authentication/email_verify.html', context)

def password_reset(request, key):
context = {
'key': key,
}
return render(request, 'Authentication/password_reset.html', context)
Здесь всё довольно прямолинейно, получаем ключ из URL, сохраняем его в контекст при рендеринге и вставляем key в качестве значения атрибута data-key в контейнерах. Дальше этот атрибут будет использовать Реакт для отправки обратных запросов.

Заключение

Я закончил добавлять систему аутентификации и регистрации моих гостей на сайте. Конечно, можно ещё добавить вход и регистрацию через социальные сети или на основе отправляемых ключей подтверждения. Всё это будет позже и возможно уже в других проектах, но точно не в этой статье, она и так получилась слишком большой.
Текущую и полную версию сайта-проекта ты можешь скачать здесь.
В следующей серии мы займёмся переводами нашего сайта на другие языки, ну а пока, до скорых встреч. ( ̄︶ ̄)↗ 


Комментарии

(0)
captcha
Отправить
Сейчас тут пусто. Буть первым (o゚v゚)ノ