Проблематика стандартной аутентификации Django
Стандартная система аутентификации Django, основанная на django.contrib.auth, по умолчанию использует username в качестве основного идентификатора пользователя. В современных веб-приложениях пользователи часто предпочитают использовать свои адреса электронной почты или номера телефонов для входа, что стандартная система не поддерживает "из коробки".
Преимущества использования электронной почты или телефона для входа
Удобство для пользователя: Большинство пользователей легче запоминают свою электронную почту или номер телефона, чем уникальный username.
Уникальность: Адреса электронной почты и (в большинстве случаев) номера телефонов являются уникальными идентификаторами.
Верификация: Email и телефонные номера могут быть легко верифицированы, что повышает безопасность аккаунта.
Восстановление доступа: Процессы восстановления пароля упрощаются при использовании email или телефона.
Обзор различных подходов к реализации
Существует несколько способов реализовать вход по email или телефону в Django:
Кастомизация стандартной модели User: Заменить поле username на email или добавить поле phone_number и использовать его для аутентификации.
Использование кастомных бэкендов аутентификации: Создать бэкенды, которые проверяют учетные данные по полям email или phone_number.
Комбинированный подход: Позволить пользователю вводить в одно поле либо email, либо номер телефона, и автоматически определять тип идентификатора.
В этой статье мы рассмотрим реализацию каждого из этих подходов.
Реализация аутентификации по электронной почте
Создание пользовательской модели User с полем email
Первый шаг — создание кастомной модели пользователя, наследующей AbstractBaseUser и PermissionsMixin. В этой модели мы сделаем поле email уникальным и используем его как USERNAME_FIELD.
# models.py
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin
)
from django.db import models
from django.utils import timezone
from typing import Optional, Any
class CustomUserManager(BaseUserManager):
"""Менеджер для кастомной модели пользователя."""
def _create_user(
self,
email: str,
password: Optional[str],
is_staff: bool,
is_superuser: bool,
**extra_fields: Any
) -> 'CustomUser':
"""Создает и сохраняет пользователя с email и паролем."""
if not email:
raise ValueError('The given email must be set')
email = self.normalize_email(email)
user = self.model(
email=email,
is_staff=is_staff,
is_active=True,
is_superuser=is_superuser,
date_joined=timezone.now(),
**extra_fields
)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(
self,
email: str,
password: Optional[str] = None,
**extra_fields: Any
) -> 'CustomUser':
"""Создает обычного пользователя."""
return self._create_user(email, password, False, False, **extra_fields)
def create_superuser(
self,
email: str,
password: Optional[str] = None,
**extra_fields: Any
) -> 'CustomUser':
"""Создает суперпользователя."""
return self._create_user(email, password, True, True, **extra_fields)
class CustomUser(AbstractBaseUser, PermissionsMixin):
"""Кастомная модель пользователя с email в качестве логина."""
email = models.EmailField('email address', unique=True)
first_name = models.CharField('first name', max_length=150, blank=True)
last_name = models.CharField('last name', max_length=150, blank=True)
is_staff = models.BooleanField(
'staff status',
default=False,
help_text='Designates whether the user can log into this admin site.',
)
is_active = models.BooleanField(
'active',
default=True,
help_text=(
'Designates whether this user should be treated as active. '
'Unselect this instead of deleting accounts.'
),
)
date_joined = models.DateTimeField('date joined', default=timezone.now)
objects = CustomUserManager()
EMAIL_FIELD = 'email'
USERNAME_FIELD = 'email' # Используем email как основной идентификатор
REQUIRED_FIELDS = [] # Email и Password требуются по умолчанию
class Meta:
verbose_name = 'user'
verbose_name_plural = 'users'
def clean(self) -> None:
super().clean()
self.email = self.__class__.objects.normalize_email(self.email)
def get_full_name(self) -> str:
"""Возвращает first_name плюс last_name с пробелом между ними."""
full_name = '%s %s' % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self) -> str:
"""Возвращает короткое имя (first_name)."""
return self.first_nameНе забудьте указать вашу кастомную модель в settings.py:
# settings.py
AUTH_USER_MODEL = 'yourapp.CustomUser'И выполнить миграции:
python manage.py makemigrations yourrapp
python manage.py migrateНастройка AUTHENTICATION_BACKENDS для использования email
Хотя мы установили USERNAME_FIELD = 'email', стандартный бэкенд ModelBackend все еще может работать, так как он достаточно гибок. Однако, для явного указания логики аутентификации по email, можно создать кастомный бэкенд.
# backends.py
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from django.db.models import Q
from typing import Optional
UserModel = get_user_model()
class EmailBackend(ModelBackend):
"""Аутентифицирует пользователя по email."""
def authenticate(
self,
request,
username: Optional[str] = None,
password: Optional[str] = None,
**kwargs
) -> Optional[UserModel]:
"""Переопределенный метод аутентификации."""
try:
# Ищем пользователя по email (case-insensitive)
user = UserModel.objects.get(email__iexact=username)
except UserModel.DoesNotExist:
# Запускаем стандартную проверку username, если нужно
# UserModel().set_password(password)
return None
except UserModel.MultipleObjectsReturned:
# В теории не должно происходить из-за unique=True
user = UserModel.objects.filter(email__iexact=username).order_by('id').first()
if user and user.check_password(password) and self.user_can_authenticate(user):
return user
return None
def get_user(self, user_id: int) -> Optional[UserModel]:
"""Получает пользователя по ID."""
try:
return UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist:
return NoneДобавьте этот бэкенд в settings.py:
# settings.py
AUTHENTICATION_BACKENDS = [
'yourapp.backends.EmailBackend', # Наш кастомный бэкенд
'django.contrib.auth.backends.ModelBackend', # Стандартный, на всякий случай
]Создание формы для входа по электронной почте
Используем стандартную AuthenticationForm, но изменим метку поля username.
# forms.py
from django import forms
from django.contrib.auth.forms import AuthenticationForm
class EmailAuthenticationForm(AuthenticationForm):
"""Форма аутентификации по email."""
username = forms.EmailField(
label="Email",
widget=forms.EmailInput(attrs={'autofocus': True})
)Обработка входа пользователя и редирект
Используем стандартное представление LoginView, указав нашу кастомную форму.
# views.py
from django.contrib.auth.views import LoginView
from .forms import EmailAuthenticationForm
class CustomLoginView(LoginView):
"""Представление для входа по email."""
form_class = EmailAuthenticationForm
template_name = 'registration/login.html'В urls.py:
# urls.py
from django.urls import path
from .views import CustomLoginView
urlpatterns = [
path('login/', CustomLoginView.as_view(), name='login'),
# ... другие URL
]Реализация аутентификации по номеру телефона
Добавление поля phone_number в пользовательскую модель
Добавим поле phone_number в нашу CustomUser модель. Важно сделать его уникальным и индексируемым.
# models.py
# ... (импорты и CustomUserManager как выше)
from phonenumber_field.modelfields import PhoneNumberField
class CustomUser(AbstractBaseUser, PermissionsMixin):
# ... (поля email, first_name, last_name, is_staff, is_active, date_joined)
email = models.EmailField('email address', unique=True, null=True, blank=True) # Может быть необязательным
phone_number = PhoneNumberField(
'phone number',
unique=True,
null=True,
blank=True, # Разрешаем null/blank, если email основной или вход комбинированный
help_text='Required. E.g., +12125552368'
)
objects = CustomUserManager()
# Меняем USERNAME_FIELD, если телефон основной ИЛИ используем кастомный бэкенд
USERNAME_FIELD = 'email' # Оставим email или создадим отдельный бэкенд для телефона
REQUIRED_FIELDS = []
class Meta:
verbose_name = 'user'
verbose_name_plural = 'users'
constraints = [
models.UniqueConstraint(
fields=['email'],
condition=Q(email__isnull=False),
name='unique_email_if_not_null'
),
models.UniqueConstraint(
fields=['phone_number'],
condition=Q(phone_number__isnull=False),
name='unique_phone_if_not_null'
)
]
# ... (остальные методы)Важно: Если вы хотите разрешить вход только по номеру телефона, установите USERNAME_FIELD = 'phone_number' и сделайте email необязательным (null=True, blank=True). Если нужен комбинированный вход, оба поля могут быть опциональными, но хотя бы одно должно быть заполнено (это нужно проверять на уровне форм/модели). Мы рассмотрим комбинированный вариант позже.
Использование сторонних библиотек для валидации номера телефона
Библиотека django-phonenumber-field (основанная на phonenumbers) отлично подходит для валидации и хранения номеров телефонов.
pip install django-phonenumber-field phonenumbersДобавьте 'phonenumber_field' в INSTALLED_APPS.
Создание формы для входа по номеру телефона
Здесь логика отличается: обычно вход по телефону реализуется через отправку одноразового пароля (OTP) по SMS.
# forms.py
from django import forms
from phonenumber_field.formfields import PhoneNumberField
class PhoneLoginForm(forms.Form):
"""Форма для запроса OTP по номеру телефона."""
phone_number = PhoneNumberField(region="RU") # Укажите нужный регион по умолчанию
class PhoneLoginVerifyForm(forms.Form):
"""Форма для ввода OTP."""
otp = forms.CharField(max_length=6, required=True)Отправка SMS с кодом подтверждения (OTP)
Для отправки SMS потребуется интеграция с SMS-шлюзом (например, Twilio, SMSRu, Vonage). Логика генерации и проверки OTP должна быть безопасной.
# services.py (пример сервиса для OTP)
import random
from django.core.cache import cache
from django.conf import settings
from typing import Optional
# Настройки (пример)
OTP_LENGTH = 6
OTP_EXPIRY_SECONDS = 300 # 5 минут
class OTPSmsService:
"""Сервис для генерации, отправки и проверки OTP."""
def generate_otp(self) -> str:
"""Генерирует случайный OTP."""
return str(random.randint(10**(OTP_LENGTH-1), 10**OTP_LENGTH - 1))
def store_otp(self, identifier: str, otp: str) -> None:
"""Сохраняет OTP в кеше с временем жизни."""
cache_key = f"otp_{identifier}"
cache.set(cache_key, otp, timeout=OTP_EXPIRY_SECONDS)
def send_otp(self, phone_number: str, otp: str) -> bool:
"""Отправляет OTP на указанный номер (реализация зависит от SMS-шлюза)."""
# Здесь должна быть логика интеграции с вашим SMS-провайдером
print(f"Sending OTP {otp} to {phone_number}") # Заглушка
# response = sms_gateway.send(to=phone_number, message=f"Your code: {otp}")
# return response.status == 'sent'
return True # Заглушка
def verify_otp(self, identifier: str, otp_entered: str) -> bool:
"""Проверяет введенный OTP."""
cache_key = f"otp_{identifier}"
stored_otp: Optional[str] = cache.get(cache_key)
if stored_otp and stored_otp == otp_entered:
cache.delete(cache_key) # Удаляем OTP после успешной проверки
return True
return False
# views.py (пример)
from django.shortcuts import render, redirect
from django.contrib.auth import login, authenticate
from django.views import View
from django.contrib import messages
from .forms import PhoneLoginForm, PhoneLoginVerifyForm
from .services import OTPSmsService
from django.contrib.auth import get_user_model
UserModel = get_user_model()
otp_service = OTPSmsService()
class PhoneLoginRequestView(View):
"""Запрос OTP для входа по телефону."""
form_class = PhoneLoginForm
template_name = 'registration/phone_login_request.html'
def get(self, request):
form = self.form_class()
return render(request, self.template_name, {'form': form})
def post(self, request):
form = self.form_class(request.POST)
if form.is_valid():
phone_number = form.cleaned_data['phone_number']
try:
user = UserModel.objects.get(phone_number=phone_number)
otp = otp_service.generate_otp()
if otp_service.send_otp(str(phone_number), otp):
otp_service.store_otp(str(phone_number), otp)
request.session['login_phone_number'] = str(phone_number) # Сохраняем номер в сессии
messages.success(request, 'Код подтверждения отправлен.')
return redirect('phone_login_verify')
else:
messages.error(request, 'Не удалось отправить SMS. Попробуйте позже.')
except UserModel.DoesNotExist:
messages.error(request, 'Пользователь с таким номером телефона не найден.')
except Exception as e:
# Логирование ошибки
messages.error(request, 'Произошла ошибка. Попробуйте позже.')
return render(request, self.template_name, {'form': form})
class PhoneLoginVerifyView(View):
"""Верификация OTP и вход."""
form_class = PhoneLoginVerifyForm
template_name = 'registration/phone_login_verify.html'
def get(self, request):
if 'login_phone_number' not in request.session:
return redirect('phone_login_request') # Редирект, если номер не сохранен
form = self.form_class()
return render(request, self.template_name, {'form': form})
def post(self, request):
if 'login_phone_number' not in request.session:
return redirect('phone_login_request')
form = self.form_class(request.POST)
phone_number = request.session['login_phone_number']
if form.is_valid():
otp_entered = form.cleaned_data['otp']
if otp_service.verify_otp(phone_number, otp_entered):
try:
# Используем authenticate с кастомным бэкендом или получаем пользователя напрямую
user = UserModel.objects.get(phone_number=phone_number)
# Важно: Не используем стандартный authenticate, так как нет пароля
# Вместо этого логиним пользователя напрямую, т.к. он подтвердил владение номером
login(request, user, backend='django.contrib.auth.backends.ModelBackend') # Указать подходящий бэкенд
del request.session['login_phone_number'] # Очищаем сессию
messages.success(request, 'Вход выполнен успешно.')
return redirect(settings.LOGIN_REDIRECT_URL)
except UserModel.DoesNotExist:
messages.error(request, 'Пользователь не найден. Странная ошибка.') # Не должно произойти
except Exception as e:
# Логирование ошибки
messages.error(request, 'Произошла ошибка при входе.')
else:
messages.error(request, 'Неверный код подтверждения.')
return render(request, self.template_name, {'form': form})Верификация OTP и вход пользователя
Логика верификации и входа пользователя представлена в PhoneLoginVerifyView выше. Ключевой момент — после успешной проверки OTP мы напрямую вызываем login(request, user, backend=...), так как пользователь подтвердил владение идентификатором (номером телефона), и традиционная проверка пароля не требуется.
Реализация аутентификации по электронной почте или номеру телефона (выбор пользователя)
Объединение логики в единой форме входа
Создадим форму, которая принимает один идентификатор (логин) и пароль.
# forms.py
from django import forms
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from phonenumbers import parse as parse_phone_number, NumberParseException
class CombinedAuthenticationForm(forms.Form):
"""Форма для входа по email или телефону с паролем."""
login = forms.CharField(
label="Email или номер телефона",
max_length=254,
widget=forms.TextInput(attrs={'autofocus': True})
)
password = forms.CharField(
label="Пароль",
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
error_messages = {
'invalid_login': "Введите корректный email или номер телефона.",
'inactive': "Этот аккаунт неактивен.",
}
def clean_login(self) -> str:
"""Валидация поля login."""
login_value = self.cleaned_data.get('login')
is_email = False
is_phone = False
# Проверка на email
try:
validate_email(login_value)
is_email = True
except ValidationError:
pass
# Проверка на телефон (упрощенная)
try:
# Используем phonenumbers для базовой проверки формата
# Для строгой валидации можно использовать PhoneNumberField.to_python
parsed_number = parse_phone_number(login_value, None) # None - без региона по умолчанию
# Дополнительные проверки формата, если нужны
if parsed_number and parsed_number.country_code and parsed_number.national_number:
is_phone = True
except NumberParseException:
pass
if not is_email and not is_phone:
raise ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
)
return login_valueОпределение типа идентификатора (email или телефон)
Тип идентификатора определяется внутри кастомного бэкенда аутентификации.
# backends.py
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from phonenumber_field.phonenumber import to_python as parse_phone_to_obj
from typing import Optional
UserModel = get_user_model()
class EmailOrPhoneBackend(ModelBackend):
"""Аутентифицирует пользователя по email или номеру телефона."""
def authenticate(
self,
request,
username: Optional[str] = None, # 'username' используется по соглашению Django
password: Optional[str] = None,
**kwargs
) -> Optional[UserModel]:
"""Переопределенный метод аутентификации."""
if username is None:
return None
# Попытка найти пользователя по email или телефону
# Нормализация email (case-insensitive)
# Нормализация телефона (с помощью phonenumbers)
try:
# Проверяем, является ли ввод email'ом
validate_email(username)
lookup = Q(email__iexact=username)
except ValidationError:
# Если не email, пробуем как телефон
try:
phone_number = parse_phone_to_obj(username)
if phone_number and phone_number.is_valid():
lookup = Q(phone_number=phone_number)
else:
# Невалидный формат телефона
return None
except Exception: # Ловим ошибки парсинга phonenumbers
return None # Не email и не телефон
try:
user = UserModel.objects.get(lookup)
except UserModel.DoesNotExist:
# Запускаем проверку пароля для защиты от user enumeration timing attacks
# UserModel().set_password(password)
return None
except UserModel.MultipleObjectsReturned:
# Если возможно несколько совпадений (например, нестрогая проверка)
user = UserModel.objects.filter(lookup).order_by('id').first()
if user and user.check_password(password) and self.user_can_authenticate(user):
return user
return None
def get_user(self, user_id: int) -> Optional[UserModel]:
"""Получает пользователя по ID."""
try:
return UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist:
return NoneОбновите AUTHENTICATION_BACKENDS в settings.py:
# settings.py
AUTHENTICATION_BACKENDS = [
'yourapp.backends.EmailOrPhoneBackend', # Наш новый бэкенд
'django.contrib.auth.backends.ModelBackend',
]Использование условной логики для обработки различных типов входа
В представлении LoginView теперь можно использовать CombinedAuthenticationForm и EmailOrPhoneBackend.
# views.py
from django.contrib.auth.views import LoginView
from .forms import CombinedAuthenticationForm
class CombinedLoginView(LoginView):
"""Представление для входа по email или телефону."""
form_class = CombinedAuthenticationForm
template_name = 'registration/login.html' # Можно использовать тот же шаблон
# authentication_form = CombinedAuthenticationForm # Можно указать явноЗамените CustomLoginView на CombinedLoginView в urls.py.
Примечание: Если вы хотите также поддерживать вход по телефону через OTP (без пароля) наряду с email/телефон + пароль, вам потребуется более сложная логика в представлении входа, которая сначала определяет тип идентификатора, а затем либо запрашивает пароль, либо инициирует процесс OTP.
Дополнительные соображения и безопасность
Обработка случаев восстановления пароля
Стандартные представления Django для сброса пароля (PasswordResetView, PasswordResetConfirmView и т.д.) по умолчанию ищут пользователя по email. Если вы используете email как USERNAME_FIELD или основной идентификатор, они будут работать. Если вы используете телефон или комбинированный вход, вам может потребоваться кастомизировать эти представления или формы для поиска пользователя по соответствующему полю.
Восстановление через Email: Стандартный механизм работает, если email используется.
Восстановление через SMS: Требуется кастомная реализация, аналогичная OTP-входу: форма запроса сброса по номеру телефона, отправка SMS со ссылкой или кодом, форма ввода нового пароля после верификации.
Защита от перебора кодов OTP и brute-force атак
Ограничение попыток ввода OTP: Внедрите ограничение на количество неверных попыток ввода OTP для одного номера телефона или сессии (например, 3-5 попыток), после чего потребуется повторный запрос кода или временная блокировка.
Ограничение частоты запросов OTP: Не позволяйте запрашивать OTP слишком часто для одного и того же номера (например, не чаще раза в 60 секунд).
Rate Limiting для входа: Используйте библиотеки типа django-ratelimit для ограничения количества попыток входа с одного IP-адреса или для одного аккаунта.
Мониторинг и логирование: Ведите подробные логи попыток входа и запросов OTP для выявления подозрительной активности.
Двухфакторная аутентификация (2FA) для повышения безопасности
Даже при входе по email/паролю или телефон/пароль, рекомендуется предлагать пользователям настроить 2FA (например, через TOTP-приложения типа Google Authenticator или SMS) как дополнительный уровень защиты.
Использование Captcha для защиты от ботов
На формах входа, регистрации и запроса OTP/сброса пароля рекомендуется использовать CAPTCHA (например, reCAPTCHA или hCaptcha) для предотвращения автоматизированных атак со стороны ботов.