From 796fd8fe18125c106ccc44e9983252370693c1cc Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 8 Jan 2026 22:09:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20accounts:=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myproject/accounts/backends.py | 90 +++++++++++++++++++ myproject/accounts/migrations/0001_initial.py | 7 +- myproject/accounts/models.py | 18 +++- myproject/accounts/views.py | 39 ++++++-- 4 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 myproject/accounts/backends.py diff --git a/myproject/accounts/backends.py b/myproject/accounts/backends.py new file mode 100644 index 0000000..7eaaa63 --- /dev/null +++ b/myproject/accounts/backends.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +Authentication backend для CustomUser (пользователей тенантов). + +Этот backend используется для аутентификации пользователей магазинов. +Работает ТОЛЬКО на tenant доменах, НЕ на public домене. + +ВАЖНО: CustomUser теперь в TENANT_APPS - каждый тенант имеет свою таблицу! +Backend работает с таблицей accounts_customuser в текущей tenant schema. +""" +from django.contrib.auth.backends import ModelBackend +from django.db import connection + + +class TenantUserBackend(ModelBackend): + """ + Backend аутентификации для CustomUser (tenant-only). + + Особенности: + - Работает ТОЛЬКО на tenant доменах (не на public) + - Ищет пользователя в таблице accounts_customuser текущей tenant schema + - Один email в разных тенантах = разные записи в разных таблицах БД + + Пользователь из tenant A физически не существует в tenant B. + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + Аутентификация CustomUser по email и паролю. + + Args: + request: HTTP запрос + username: Email пользователя + password: Пароль + + Returns: + CustomUser если аутентификация успешна, иначе None + """ + # Не работает на public домене + schema_name = getattr(connection, 'schema_name', 'public') + if schema_name == 'public': + return None + + if username is None or password is None: + return None + + # Импортируем напрямую, не через get_user_model() + # т.к. AUTH_USER_MODEL теперь PlatformAdmin + from accounts.models import CustomUser + + try: + # django-tenants автоматически направляет запрос в текущую schema + user = CustomUser.objects.get(email=username) + except CustomUser.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a non-existing user + CustomUser().set_password(password) + return None + + if not user.check_password(password): + return None + + if not self.user_can_authenticate(user): + return None + + return user + + def get_user(self, user_id): + """ + Получение CustomUser по ID. + + На public домене возвращает None. + """ + schema_name = getattr(connection, 'schema_name', 'public') + if schema_name == 'public': + return None + + from accounts.models import CustomUser + + try: + return CustomUser.objects.get(pk=user_id) + except CustomUser.DoesNotExist: + return None + + def user_can_authenticate(self, user): + """ + Проверка что пользователь активен. + """ + is_active = getattr(user, 'is_active', None) + return is_active or is_active is None diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index 297a6bf..279399f 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2026-01-03 23:23 +# Generated by Django 5.0.10 on 2026-01-08 15:58 import django.contrib.auth.validators import django.utils.timezone @@ -38,9 +38,8 @@ class Migration(migrations.Migration): ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_user_set', to='auth.permission', verbose_name='user permissions')), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + 'verbose_name': 'Пользователь магазина', + 'verbose_name_plural': 'Пользователи магазина', }, ), ] diff --git a/myproject/accounts/models.py b/myproject/accounts/models.py index b68f58f..adb8453 100644 --- a/myproject/accounts/models.py +++ b/myproject/accounts/models.py @@ -43,17 +43,27 @@ class CustomUserManager(BaseUserManager): class CustomUser(AbstractUser): + """ + Пользователь тенанта (магазина). + + ВАЖНО: Эта модель в TENANT_APPS - каждый тенант имеет свою таблицу! + Один email в разных тенантах = разные записи в разных схемах БД. + Полная изоляция обеспечивается на уровне PostgreSQL schemas. + + НЕ является AUTH_USER_MODEL (это PlatformAdmin). + """ email = models.EmailField(unique=True) name = models.CharField(max_length=100) + is_email_confirmed = models.BooleanField(default=False) email_confirmation_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) email_confirmed_at = models.DateTimeField(null=True, blank=True) password_reset_token = models.UUIDField(null=True, blank=True, editable=False, unique=True) - + USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['name'] - objects = CustomUserManager() # Добавляем кастомный менеджер + objects = CustomUserManager() # Изменяем related_name для избежания конфликта с встроенной моделью User groups = models.ManyToManyField( @@ -71,6 +81,10 @@ class CustomUser(AbstractUser): help_text='Specific permissions for this user.', ) + class Meta: + verbose_name = "Пользователь магазина" + verbose_name_plural = "Пользователи магазина" + def __str__(self): return self.email diff --git a/myproject/accounts/views.py b/myproject/accounts/views.py index 9cfcdb1..6873c5f 100644 --- a/myproject/accounts/views.py +++ b/myproject/accounts/views.py @@ -11,30 +11,51 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.decorators import login_required from django.contrib.auth import update_session_auth_hash from django.contrib.auth.forms import PasswordChangeForm +from django.db import connection from .forms import PasswordResetForm from .models import CustomUser import uuid def login_view(request): + """ + Страница входа для пользователей тенанта (CustomUser). + + SECURITY: Работает ТОЛЬКО на tenant доменах! + На public домене перенаправляет на страницу логина PlatformAdmin. + """ + # Проверяем что мы НЕ на public домене + schema_name = getattr(connection, 'schema_name', 'public') + if schema_name == 'public': + messages.info( + request, + 'Вход для пользователей магазинов доступен только на домене вашего магазина. ' + 'Если вы администратор платформы, используйте /platform/login/' + ) + return redirect('platform_admin:login') + if request.method == 'POST': email = request.POST.get('email') password = request.POST.get('password') - + # Используем email как логин user = authenticate(request, username=email, password=password) - + if user is not None: - if user.is_email_confirmed: # Проверяем, подтвержден ли email - login(request, user) + # Проверяем, что это CustomUser (пользователь магазина), а не PlatformAdmin + if not isinstance(user, CustomUser): + # Не раскрываем информацию о существовании других типов пользователей + messages.error(request, 'Пользователь не найден.') + elif not user.is_email_confirmed: + messages.error(request, 'Пожалуйста, подтвердите ваш email для входа.') + else: + login(request, user, backend='accounts.backends.TenantUserBackend') # Перенаправляем на главную страницу после успешного входа next_page = request.GET.get('next', 'index') # Если есть параметр next, переходим туда return redirect(next_page) - else: - messages.error(request, 'Пожалуйста, подтвердите ваш email для входа.') else: messages.error(request, 'Неверный email или пароль.') - + return render(request, 'login.html') @@ -203,9 +224,9 @@ def password_setup_confirm(request, token): registration.password_setup_token_created_at = None registration.save() - # Автоматический вход + # Автоматический вход (используем TenantUserBackend) connection.set_tenant(tenant) - login(request, owner, backend='django.contrib.auth.backends.ModelBackend') + login(request, owner, backend='accounts.backends.TenantUserBackend') messages.success( request,