Аутентификация пользователей является фундаментальной частью большинства веб-приложений. Традиционные методы, такие как пара логин/пароль или вход через социальные сети, широко распространены, но аутентификация по номеру телефона набирает популярность благодаря удобству и повышению уровня безопасности в определенных сценариях.
Зачем использовать номер телефона для аутентификации?
Удобство: Пользователям не нужно запоминать еще один пароль. Номер телефона всегда под рукой.
Безопасность: Двухфакторная аутентификация (2FA) или беспарольный вход через SMS снижают риск несанкционированного доступа, так как для входа требуется физический доступ к устройству.
Верификация: Номер телефона часто является более надежным идентификатором, чем email, и может использоваться для верификации пользователя.
Снижение фрода: Позволяет уменьшить количество фейковых регистраций.
Обзор существующих решений и их ограничения
Существуют готовые пакеты для Django, реализующие аутентификацию по номеру телефона (например, django-otp). Однако они могут не полностью соответствовать специфическим требованиям проекта, иметь избыточную функциональность или накладывать ограничения на кастомизацию пользовательской модели и процессов.
Постановка задачи: разработка собственной системы аутентификации
В этой статье мы рассмотрим процесс создания кастомной системы аутентификации в Django, использующей номер телефона в качестве основного идентификатора пользователя. Мы реализуем регистрацию и вход с подтверждением по SMS, создадим собственную модель пользователя и настроим необходимые компоненты.
Подготовка Django-проекта и установка необходимых библиотек
Перед началом убедитесь, что у вас установлен Python и Django.
Создание нового Django-проекта или использование существующего
django-admin startproject phone_auth_project
cd phone_auth_project
python manage.py startapp usersУстановка библиотек: `django-phonenumber-field` и `twilio` (или аналоги)
Нам понадобятся библиотеки для работы с номерами телефонов и отправки SMS.
django-phonenumber-field: Интегрирует библиотеку phonenumbers (форк libphonenumber от Google) в Django, предоставляя поле модели и виджет формы для валидации и форматирования номеров.
twilio: Популярный сервис для отправки SMS и других коммуникаций (можно использовать аналоги, например, Vonage, MessageBird, или отечественные сервисы, предоставляющие API).
pip install django django-phonenumber-field phonenumbers[phonenumberslite] twilioПримечание: phonenumbers[phonenumberslite] устанавливает облегченную версию метаданных, достаточную для большинства случаев.
Настройка `settings.py`: добавление приложения и конфигурация `phonenumber-field`
Добавьте users и phonenumber_field в INSTALLED_APPS:
# settings.py
INSTALLED_APPS = [
# ... стандартные приложения Django
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Сторонние приложения
'phonenumber_field',
# Наши приложения
'users',
]
# Указываем нашу кастомную модель пользователя
AUTH_USER_MODEL = 'users.User'
# Настройки Twilio (или аналога) - рекомендуется использовать переменные окружения
TWILIO_ACCOUNT_SID = 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
TWILIO_AUTH_TOKEN = 'your_auth_token'
TWILIO_PHONE_NUMBER = '+15017122661'Реализация модели пользователя с использованием номера телефона
Стандартная модель User в Django использует username. Мы создадим кастомную модель, где основным идентификатором будет номер телефона.
Создание кастомной модели пользователя, наследующейся от `AbstractBaseUser` и `PermissionsMixin`
Использование AbstractBaseUser дает полный контроль над полями модели, в то время как PermissionsMixin добавляет стандартные поля и методы для работы с правами Django.
Добавление поля `phone_number` с использованием `PhoneNumberField`
Поле PhoneNumberField из django-phonenumber-field обеспечивает валидацию и хранение номеров в стандартном формате E.164.
Создание кастомного менеджера пользователей для работы с номером телефона
Менеджер необходим для управления созданием пользователей (create_user) и суперпользователей (create_superuser), используя номер телефона вместо username.
# users/models.py
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin
)
from django.db import models
from phonenumber_field.modelfields import PhoneNumberField
from django.utils import timezone
from typing import Optional
class UserManager(BaseUserManager):
"""Кастомный менеджер для модели User."""
def create_user(
self,
phone_number: str,
password: Optional[str] = None,
**extra_fields
) -> 'User':
"""
Создает и сохраняет пользователя с указанным номером телефона и паролем.
"""
if not phone_number:
raise ValueError('The Phone Number field must be set')
# Нормализация номера телефона не требуется явно,
# т.к. PhoneNumberField делает это автоматически.
user = self.model(phone_number=phone_number, **extra_fields)
# Установка пароля (даже если беспарольный вход, Django требует пароль)
# Можно генерировать непригодный пароль для беспарольных систем.
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(
self,
phone_number: str,
password: Optional[str] = None,
**extra_fields
) -> 'User':
"""
Создает и сохраняет суперпользователя.
"""
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True) # Суперпользователь должен быть активен
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')
# Пароль обязателен для суперпользователя для доступа к админке
if not password:
raise ValueError('Superuser must have a password.')
return self.create_user(phone_number, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""Кастомная модель пользователя с номером телефона в качестве основного идентификатора."""
phone_number = PhoneNumberField(
unique=True,
verbose_name='Номер телефона',
help_text='Введите номер телефона в международном формате, например +79991234567'
)
first_name = models.CharField('Имя', max_length=150, blank=True)
last_name = models.CharField('Фамилия', max_length=150, blank=True)
is_staff = models.BooleanField(
'Статус персонала', default=False,
help_text='Определяет, может ли пользователь войти в админку.'
)
is_active = models.BooleanField(
'Активен', default=True,
help_text='Снимите галочку вместо удаления аккаунта.'
)
date_joined = models.DateTimeField('Дата регистрации', default=timezone.now)
# Указываем поле, используемое в качестве уникального идентификатора
USERNAME_FIELD = 'phone_number'
# Поля, запрашиваемые при создании суперпользователя через createsuperuser
REQUIRED_FIELDS = ['first_name', 'last_name']
objects = UserManager()
def __str__(self) -> str:
return str(self.phone_number)
class Meta:
verbose_name = 'Пользователь'
verbose_name_plural = 'Пользователи'Миграции и применение изменений к базе данных
python manage.py makemigrations users
python manage.py migrateТеперь можно создать суперпользователя:
python manage.py createsuperuser
Система запросит номер телефона, имя, фамилию и пароль.
Создание процессов регистрации и входа по номеру телефона
Реализуем механизм отправки одноразового пароля (OTP) по SMS для подтверждения номера.
Регистрация: отправка SMS с кодом подтверждения
Процесс регистрации будет состоять из двух шагов:
Ввод номера телефона.
Ввод кода подтверждения, полученного по SMS.
Создание формы регистрации с полем для номера телефона
# users/forms.py
from django import forms
from phonenumber_field.formfields import PhoneNumberField
class PhoneVerificationForm(forms.Form):
"""Форма для ввода номера телефона."""
phone_number = PhoneNumberField(
region="RU", # Укажите релевантный регион для подсказок
label="Номер телефона",
help_text="Введите номер для получения кода подтверждения."
)
class OTPVerificationForm(forms.Form):
"""Форма для ввода OTP кода."""
otp_code = forms.CharField(
label="Код подтверждения",
max_length=6,
min_length=6,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'})
)Генерация и отправка SMS с кодом подтверждения (использование `twilio` или аналогичного сервиса)
Создадим утилиту для отправки SMS.
# users/utils.py
import random
from django.conf import settings
from twilio.rest import Client
from typing import Optional
def generate_otp(length: int = 6) -> str:
"""Генерирует случайный OTP код указанной длины."""
return str(random.randint(10**(length-1), 10**length - 1))
def send_sms_otp(phone_number: str, otp_code: str) -> bool:
"""Отправляет SMS с OTP кодом на указанный номер через Twilio."""
if not all([settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN, settings.TWILIO_PHONE_NUMBER]):
print("Twilio settings are not configured.")
return False # В реальном проекте лучше логировать ошибку
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
message_body = f"Ваш код подтверждения: {otp_code}"
try:
message = client.messages.create(
body=message_body,
from_=settings.TWILIO_PHONE_NUMBER,
to=str(phone_number) # PhoneNumberField возвращает объект, преобразуем в строку
)
print(f"SMS sent successfully to {phone_number}. SID: {message.sid}")
return True
except Exception as e:
# В реальном проекте здесь должна быть robust обработка ошибок и логирование
print(f"Error sending SMS to {phone_number}: {e}")
return False
# Пример хранения OTP в сессии (для простоты, можно использовать Redis/Cache)
def store_otp_in_session(request, phone_number: str, otp: str):
request.session['otp_code'] = otp
request.session['phone_number_for_verification'] = str(phone_number)
# Установить время жизни OTP
request.session.set_expiry(300) # 5 минут
def verify_otp_from_session(request, entered_otp: str) -> Optional[str]:
stored_otp = request.session.get('otp_code')
phone_number = request.session.get('phone_number_for_verification')
if stored_otp == entered_otp:
# Удаляем OTP из сессии после успешной проверки
request.session.pop('otp_code', None)
request.session.pop('phone_number_for_verification', None)
return phone_number
return NoneПроверка кода подтверждения и создание нового пользователя
# users/views.py
from django.shortcuts import render, redirect
from django.views import View
from django.contrib.auth import login, get_user_model
from .forms import PhoneVerificationForm, OTPVerificationForm
from .utils import generate_otp, send_sms_otp, store_otp_in_session, verify_otp_from_session
User = get_user_model()
class RegisterPhoneView(View):
"""Шаг 1: Ввод номера телефона."""
form_class = PhoneVerificationForm
template_name = 'users/register_phone.html'
def get(self, request, *args, **kwargs):
form = self.form_class()
return render(request, self.template_name, {'form': form})
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST)
if form.is_valid():
phone_number = form.cleaned_data['phone_number']
# Проверка, не зарегистрирован ли уже такой номер
if User.objects.filter(phone_number=phone_number).exists():
form.add_error('phone_number', 'Этот номер уже зарегистрирован.')
return render(request, self.template_name, {'form': form})
otp = generate_otp()
if send_sms_otp(phone_number, otp):
store_otp_in_session(request, phone_number, otp)
return redirect('users:verify_otp') # Перенаправление на страницу ввода OTP
else:
form.add_error(None, 'Не удалось отправить SMS. Попробуйте позже.')
return render(request, self.template_name, {'form': form})
class VerifyOTPView(View):
"""Шаг 2: Ввод и проверка OTP."""
form_class = OTPVerificationForm
template_name = 'users/verify_otp.html'
def get(self, request, *args, **kwargs):
if not request.session.get('phone_number_for_verification'):
return redirect('users:register_phone') # Нельзя попасть сюда без номера
form = self.form_class()
return render(request, self.template_name, {'form': form})
def post(self, request, *args, **kwargs):
if not request.session.get('phone_number_for_verification'):
return redirect('users:register_phone')
form = self.form_class(request.POST)
if form.is_valid():
entered_otp = form.cleaned_data['otp_code']
verified_phone_number = verify_otp_from_session(request, entered_otp)
if verified_phone_number:
# OTP верный, создаем пользователя
try:
user = User.objects.create_user(phone_number=verified_phone_number)
# Можно сразу авторизовать пользователя
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
return redirect('home') # Перенаправить на главную страницу или дашборд
except Exception as e:
# Обработка возможных ошибок создания пользователя
form.add_error(None, f'Ошибка при создании пользователя: {e}')
else:
form.add_error('otp_code', 'Неверный код подтверждения или время истекло.')
return render(request, self.template_name, {'form': form})Примечание: Не забудьте создать соответствующие URL-шаблоны (urls.py) и HTML-шаблоны.
Реализация процесса входа: Верификация номера телефона
Процесс входа аналогичен регистрации, но вместо создания нового пользователя мы ищем существующего и аутентифицируем его.
Форма входа с полем для номера телефона
Можно использовать ту же PhoneVerificationForm.
Отправка SMS с кодом подтверждения для входа
Логика похожа на регистрацию, но сначала проверяем, существует ли пользователь с таким номером.
# users/views.py
from django.contrib.auth import login
class LoginPhoneView(View):
"""Шаг 1: Ввод номера телефона для входа."""
form_class = PhoneVerificationForm
template_name = 'users/login_phone.html'
def get(self, request, *args, **kwargs):
form = self.form_class()
return render(request, self.template_name, {'form': form})
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST)
if form.is_valid():
phone_number = form.cleaned_data['phone_number']
try:
user = User.objects.get(phone_number=phone_number)
if not user.is_active:
form.add_error('phone_number', 'Аккаунт неактивен.')
return render(request, self.template_name, {'form': form})
except User.DoesNotExist:
form.add_error('phone_number', 'Пользователь с таким номером не найден.')
return render(request, self.template_name, {'form': form})
otp = generate_otp()
if send_sms_otp(phone_number, otp):
store_otp_in_session(request, phone_number, otp)
# Важно указать, что это процесс входа, а не регистрации
request.session['login_attempt'] = True
return redirect('users:verify_login_otp') # Отдельный URL для верификации входа
else:
form.add_error(None, 'Не удалось отправить SMS. Попробуйте позже.')
return render(request, self.template_name, {'form': form})Верификация кода и аутентификация пользователя
# users/views.py
class VerifyLoginOTPView(View):
"""Шаг 2: Ввод и проверка OTP для входа."""
form_class = OTPVerificationForm
template_name = 'users/verify_otp.html' # Можно использовать тот же шаблон
def get(self, request, *args, **kwargs):
if not request.session.get('phone_number_for_verification') or not request.session.get('login_attempt'):
return redirect('users:login_phone')
form = self.form_class()
return render(request, self.template_name, {'form': form, 'is_login': True})
def post(self, request, *args, **kwargs):
if not request.session.get('phone_number_for_verification') or not request.session.get('login_attempt'):
return redirect('users:login_phone')
form = self.form_class(request.POST)
if form.is_valid():
entered_otp = form.cleaned_data['otp_code']
verified_phone_number = verify_otp_from_session(request, entered_otp)
if verified_phone_number:
try:
user = User.objects.get(phone_number=verified_phone_number)
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
request.session.pop('login_attempt', None) # Очищаем флаг попытки входа
return redirect('home')
except User.DoesNotExist:
# Маловероятно, но возможно, если пользователь был удален между шагами
form.add_error(None, 'Произошла ошибка. Пользователь не найден.')
except Exception as e:
form.add_error(None, f'Ошибка аутентификации: {e}')
else:
form.add_error('otp_code', 'Неверный код подтверждения или время истекло.')
return render(request, self.template_name, {'form': form, 'is_login': True})Обработка ошибок и обеспечение безопасности
Важно предусмотреть обработку различных ошибок: неверный номер, пользователь уже существует/не существует, ошибка отправки SMS, неверный OTP, истекшее время OTP.
Безопасность и оптимизация
Реализация аутентификации по номеру телефона требует внимания к безопасности.
Защита от перебора кодов подтверждения (rate limiting)
Необходимо ограничить количество попыток ввода OTP и запросов на отправку SMS для одного номера телефона или IP-адреса. Используйте библиотеки типа django-ratelimit или реализуйте собственную логику (например, с использованием кэша Django или Redis).
# Пример использования django-ratelimit (требует установки и настройки)
from ratelimit.decorators import ratelimit
# В views.py
class RegisterPhoneView(View):
# ...
@ratelimit(key='ip', rate='5/m', block=True)
@ratelimit(key=lambda r, g: r.POST.get('phone_number', 'invalid'), rate='3/h', block=True)
def post(self, request, *args, **kwargs):
# ... логика отправки SMS
pass
class VerifyOTPView(View):
# ...
@ratelimit(key='ip', rate='10/m', block=True)
@ratelimit(key='session:phone_number_for_verification', rate='5/m', block=True)
def post(self, request, *args, **kwargs):
# ... логика проверки OTP
passИспользование HTTPS для защиты данных
Всегда используйте HTTPS для шифрования трафика между клиентом и сервером, особенно при передаче номеров телефонов и кодов подтверждения.
Валидация и очистка номера телефона
django-phonenumber-field выполняет базовую валидацию. Убедитесь, что формат номера соответствует ожиданиям вашего SMS-провайдера. Дополнительная очистка может потребоваться для удаления пробелов, скобок и дефисов перед сохранением или отправкой.
Обработка ошибок отправки SMS
Сервисы SMS могут быть временно недоступны или возвращать ошибки (например, неверный номер, недостаток средств). Реализуйте надежную обработку таких ситуаций: логирование ошибок, информирование пользователя, возможно, механизм повторной отправки.
Создание собственной системы аутентификации по номеру телефона в Django — это выполнимая задача, позволяющая гибко настроить процесс под нужды проекта. Ключевыми моментами являются выбор надежного SMS-провайдера, реализация безопасного хранения и проверки OTP, а также защита от атак перебора.