Как реализовать вход в Django с использованием номера телефона?

Аутентификация пользователей является фундаментальной частью большинства веб-приложений. Традиционные методы, такие как пара логин/пароль или вход через социальные сети, широко распространены, но аутентификация по номеру телефона набирает популярность благодаря удобству и повышению уровня безопасности в определенных сценариях.

Зачем использовать номер телефона для аутентификации?

Удобство: Пользователям не нужно запоминать еще один пароль. Номер телефона всегда под рукой.

Безопасность: Двухфакторная аутентификация (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, а также защита от атак перебора.


Добавить комментарий