Перейти к содержанию

Система аутентификации

Архитектура

Система аутентификации построена на основе нескольких бэкендов, каждый из которых отвечает за свой метод аутентификации. Это позволяет гибко настраивать способы входа пользователей в систему.

Основные компоненты

  1. Django ModelBackend (CustomModelBackend)
  2. Реализован в authentification_backend.py
  3. Стандартная аутентификация Django
  4. Проверяет учетные данные в локальной базе данных
  5. Используется как fallback, если другие методы недоступны

  6. LDAP Backend (LDAPBackend)

  7. Реализован в authentification_ldap.py
  8. Использует библиотеку python-ldap
  9. Поддерживает конфигурацию через переменные окружения
  10. Может работать как в эксклюзивном режиме, так и с fallback на локальную БД
  11. При определении имени пользователя для LDAP использует следующий приоритет:

    1. sso_login - если заполнен
    2. ldap_username - если заполнен
    3. email - если заполнен
    4. username - как последний вариант
  12. SSO Backend (SSOBackend)

  13. Реализован в authentification_sso.py
  14. Поддерживает несколько провайдеров SSO (LDAP, Azure AD, Omada IDM)
  15. Использует иерархическую стратегию выбора провайдера:
    1. Эксклюзивный SSO (если настроен)
    2. Пользовательский SSO (если привязан к пользователю)
    3. Локальная аутентификация (fallback)
  16. Поддерживает fallback на email-аутентификацию
  17. Реализует детальное логирование всех попыток аутентификации
  18. Классифицирует ошибки аутентификации для аналитики

Модели данных

Основная модель SSO

class SSO(Model):
    TYPE_CHOICES = [
        ('ldap', 'LDAP'),
        ('azure', 'Azure AD'),
        ('omada', 'Omada IDM')
    ]
    name = models.CharField(max_length=255)
    type = models.CharField(max_length=50, choices=TYPE_CHOICES)
    exclusive = models.BooleanField(default=False)

Провайдер-специфичные модели

  1. SSOLDAP

    class SSOLDAP(models.Model):
        sso = models.OneToOneField(SSO, on_delete=models.CASCADE, primary_key=True)
        server_uri = models.CharField(max_length=255)
        base_dn = models.CharField(max_length=255)
    

  2. SSOAzure

    class SSOAzure(models.Model):
        sso = models.OneToOneField(SSO, on_delete=models.CASCADE, primary_key=True)
        tenant_id = models.CharField(max_length=255)
        client_id = models.CharField(max_length=255)
        client_secret = models.CharField(max_length=255)
    

  3. SSOOmada

    class SSOOmada(models.Model):
        sso = models.OneToOneField(SSO, on_delete=models.CASCADE, primary_key=True)
        server_uri = models.CharField(max_length=255)
        client_id = models.CharField(max_length=255)
        client_secret = models.CharField(max_length=255)
    

Процесс аутентификации

1. Инициализация

def authenticate(self, request, username=None, password=None, **kwargs):
    if not username or not password:
        return None

    try:
        user = User.objects.get(username=username)
        exclusive_sso = SSO.objects.filter(exclusive=True).first()
        # ...

2. Выбор метода аутентификации

def authenticate(self, request, username=None, password=None, **kwargs):
    user = User.objects.get(username=username)
    exclusive_sso = SSO.objects.filter(exclusive=True).first()

    if exclusive_sso:
        return self._authenticate_with_sso(user, password, exclusive_sso, "exclusive")
    elif user.sso:
        return self._authenticate_with_sso(user, password, user.sso, "user")
    else:
        return self._authenticate_local(user, password)

3. Определение имени пользователя

def _get_auth_username(self, user, sso_record):
    """
    Определяет имя пользователя для аутентификации и способ аутентификации.
    Приоритет: sso_login, ldap_username (для LDAP), username.
    """
    if not user:
        return None, 'username'

    # Для LDAP учитываем ldap_username (устаревшее поле для совместимости)
    if sso_record and sso_record.type == 'ldap':
        if user.sso_login:
            return user.sso_login, 'username'
        elif user.ldap_username:
            return user.ldap_username, 'username'
        else:
            return user.username, 'username'
    elif sso_record:
        # Для остальных SSO (Azure, Omada)
        if user.sso_login:
            return user.sso_login, 'username'
        else:
            return user.username, 'username'
    else:
        # Для локальной аутентификации используем только username
        return user.username, 'username'

4. SSO-аутентификация с fallback

def _authenticate_with_sso(self, user, password, sso_record, sso_type):
    # Определяем имя пользователя для аутентификации
    username, auth_method = self._get_auth_username(user, sso_record)

    # Первая попытка: аутентификация по username
    result, error_msg = sso_record.authenticate(username, password)

    # Fallback на email, если первая попытка не удалась
    if not result and user.email and sso_record.supports_email_auth() and auth_method == "username":
        result, error_msg = sso_record.authenticate(user.email, password, email=user.email)
        if result:
            auth_method = "email"
            username = user.email

    return result, error_msg, auth_method, username

Архитектура SSO-провайдеров

Принцип работы

SSO-провайдеры реализованы через паттерн Strategy с использованием дочерних моделей:

  1. Базовая модель SSO — содержит общие поля и выступает как диспетчер
  2. Дочерние модели (SSOLDAP, SSOAzure, SSOOmada) — содержат специфичные настройки и реализуют методы аутентификации
  3. Метод get_implementation() — возвращает конкретную реализацию провайдера

Структура провайдера

class SSO(Model):
    def get_implementation(self):
        """Возвращает конкретную реализацию SSO-провайдера"""
        if self.type == 'ldap':
            return self.ssoldap
        elif self.type == 'azure':
            return self.ssoazure
        elif self.type == 'omada':
            return self.ssoomada
        return None

    def authenticate(self, username, password, **kwargs):
        """Делегирует аутентификацию конкретной реализации"""
        impl = self.get_implementation()
        if impl:
            return impl.authenticate(username, password, **kwargs)
        return False, "SSO implementation not found"

    def supports_email_auth(self):
        """Проверяет поддержку email-аутентификации"""
        impl = self.get_implementation()
        if impl:
            return impl.supports_email_auth()
        return False

Реализация конкретного провайдера

class SSONewProvider(models.Model):
    sso = models.OneToOneField(SSO, on_delete=models.CASCADE, primary_key=True)
    api_url = models.CharField(max_length=255)
    api_key = models.CharField(max_length=255)

    def authenticate(self, username, password, **kwargs):
        """Реализация аутентификации для нового провайдера"""
        try:
            response = requests.post(
                self.api_url,
                json={
                    'username': username,
                    'password': password,
                    'api_key': self.api_key
                },
                timeout=30
            )
            return response.status_code == 200, response.text
        except requests.exceptions.RequestException as e:
            return False, str(e)

    def supports_email_auth(self):
        """Поддерживает ли провайдер email-аутентификацию"""
        return True  # или False, в зависимости от провайдера

    def get_server_info(self):
        """Возвращает информацию о сервере для логирования"""
        return {
            "type": self.sso.type,
            "name": self.sso.name,
            "exclusive": self.sso.exclusive,
            "api_url": self.api_url
            # НЕ включаем api_key для безопасности
        }

Добавление нового провайдера SSO

Для добавления нового провайдера SSO необходимо выполнить следующие шаги:

  1. Создать модель настроек провайдера (см. пример выше)

  2. Добавить тип в SSO.TYPE_CHOICES:

    TYPE_CHOICES = [
        ('ldap', 'LDAP'),
        ('azure', 'Azure AD'),
        ('omada', 'Omada IDM'),
        ('new_provider', 'New Provider Name')
    ]
    

  3. Обновить метод get_implementation() в модели SSO:

    def get_implementation(self):
        if self.type == 'new_provider':
            return self.ssonewprovider
        # ... existing providers ...
    

  4. Создать миграцию для новой модели

  5. Добавить тесты для нового провайдера в test_sso_integration.py

Безопасность

1. Хранение учетных данных

  • Пароли хранятся только в хэшированном виде с использованием Django password hashers
  • Секретные ключи провайдеров хранятся в зашифрованном виде
  • Реализована история паролей для отслеживания повторного использования

2. Защита от атак

  • Реализована защита от брутфорс-атак через ограничение попыток входа
  • Ведется журнал попыток входа с IP-адресами
  • Поддерживается временная блокировка по IP при превышении лимита попыток

3. Аудит безопасности

  • Все действия с аутентификацией логируются
  • Поддерживается отслеживание активных сессий
  • Реализован механизм принудительного завершения сессий
  • Ведется журнал изменений учетных данных

Тестирование

При добавлении нового провайдера или изменении существующей логики аутентификации необходимо:

  1. Создать unit-тесты для нового провайдера
  2. Обновить интеграционные тесты
  3. Проверить работу fallback механизмов
  4. Протестировать сценарии с эксклюзивным SSO
  5. Проверить корректность обработки ошибок

Пример тест-кейса:

def test_successful_login(client, create_users):
    """
    Проверяет успешный вход пользователя в систему.
    """
    user, _ = create_users
    response = client.post(
        "/accounts/login/?next=/",
        {"username": "user", "password": "pass"},
        REMOTE_ADDR="1.1.1.1"
    )
    assert response.wsgi_request.user.is_authenticated

Рекомендации по разработке

  1. Расширение функционала
  2. Используйте существующие абстракции при добавлении новых провайдеров
  3. Следуйте паттерну существующих реализаций
  4. Документируйте все специфичные для провайдера настройки

  5. Безопасность

  6. Всегда используйте HTTPS для внешних провайдеров
  7. Не храните секретные ключи в коде
  8. Используйте переменные окружения для конфигурации

  9. Производительность

  10. Кэшируйте результаты внешних запросов где это возможно
  11. Используйте таймауты для внешних запросов
  12. Обрабатывайте сетевые ошибки корректно

  13. Поддержка

  14. Ведите подробное логирование
  15. Добавляйте метрики для мониторинга
  16. Документируйте все изменения в API

Регистрация попыток аутентификации

См. также: Журнал попыток аутентификации для администраторов

Модели

  • AuthAttemptLog — основная модель для хранения попыток входа (src/planiqum/core/authcustom/models.py).
  • Связи: ForeignKey на User, SSO; JSON-поля для credentials_info и sso_settings.
  • Основные поля: user, username, sso, sso_repr, credentials_info, sso_settings, ip_address, user_agent, auth_backend, success, error_type, error_message, timestamp.
  • User — стандартная модель пользователя.
  • SSO, SSOLDAP, SSOAzure, SSOOmada — модели для описания SSO-провайдеров и их параметров.

Где происходит регистрация

  • Вся логика регистрации попыток централизована в backend-классах:
  • SSOBackend.authenticate (src/planiqum/core/authcustom/authentification_sso.py)
  • CustomModelBackend.authenticate (src/planiqum/core/authcustom/authentification_backend.py)
  • (устаревшее) LDAPBackend.authenticate (src/planiqum/core/authcustom/authentification_ldap.py)
  • Для SSO — используется метод _authenticate_with_sso с fallback на email-аутентификацию.
  • Для каждой попытки вызывается _log_auth_attempt() с передачей всех ключевых параметров.

Алгоритм регистрации

  1. Определение имени пользователя через _get_auth_username():
  2. Для SSO: sso_loginldap_username (только LDAP) → username
  3. Для локальной аутентификации: только username
  4. Возвращает (username_value, auth_method) где auth_method может быть 'username' или 'email'

  5. Формирование структуры credentials_info:

    credentials_info = {
        "auth_method": auth_method,  # "username" или "email"
        "login_value": login_value,  # фактическое значение для входа
        "sso_server": sso_server_info  # настройки SSO-сервера (без секретов)
    }
    

  6. Аутентификация с fallback:

  7. Первая попытка: аутентификация по username
  8. Fallback на email: если первая попытка не удалась и SSO поддерживает email-аутентификацию
  9. Обновление credentials_info при успешном fallback

  10. Классификация ошибок через _determine_error_type():

  11. connection — ошибки подключения к серверу
  12. timeout — таймауты соединения
  13. credentials — неверные учетные данные
  14. user_not_found — пользователь не найден
  15. server_error — ошибки сервера (5xx)
  16. technical — технические ошибки
  17. unknown — неклассифицированные ошибки

  18. Сохранение в журнал через _log_auth_attempt():

  19. Результат (success: bool)
  20. Тип ошибки (error_type: str)
  21. Оригинальное сообщение об ошибке (error_message: str)
  22. Технические детали: IP, user-agent, backend, время
  23. Вся регистрация происходит независимо от результата

Особенности для разных сценариев

  • Локальная аутентификация — логируется только username, sso_server пустой, auth_method = "username".
  • SSO (LDAP, Azure, Omada) — логируется реальный логин, способ аутентификации, параметры подключения, результат, ошибка.
  • Эксклюзивная SSO — параметры берутся из эксклюзивного SSO, sso_type = "exclusive".
  • Пользовательский SSO — параметры берутся из привязанного к пользователю SSO, sso_type = "user".
  • Fallback на emailauth_method = "email", login_value = email пользователя.
  • Пользователь не найден — логируется попытка с отсутствующим user, фиксируется ошибка.
  • Техническая ошибка — в error_message сохраняется оригинальный текст, в error_type — классифицированный тип.

Расширение и тестирование

  • Для добавления новых сценариев регистрации — расширяйте логику в backend-классах.
  • Для тестирования используйте pytest-тесты (см. src/planiqum/core/authcustom/tests/test_sso_integration.py).
  • Все тесты проверяют структуру credentials_info, sso_settings, success, error_type, error_message и корректность логирования.

Примеры логирования

Успешная аутентификация через LDAP

{
  "auth_method": "username",
  "login_value": "john.doe",
  "sso_server": {
    "type": "ldap",
    "name": "Corporate LDAP",
    "exclusive": false,
    "server_uri": "ldap://ldap.company.com:389",
    "base_dn": "ou=users,dc=company,dc=com"
  }
}

Fallback на email-аутентификацию

{
  "auth_method": "email",
  "login_value": "john.doe@company.com",
  "sso_server": {
    "type": "azure",
    "name": "Azure AD",
    "exclusive": true,
    "tenant_id": "12345678-1234-1234-1234-123456789012",
    "client_id": "87654321-4321-4321-4321-210987654321"
  }
}

Ошибка аутентификации

{
  "auth_method": "username",
  "login_value": "john.doe",
  "sso_server": { /* настройки SSO */ },
  "error_type": "credentials",
  "error_message": "Invalid credentials"
}

См. также: Журнал попыток аутентификации для администраторов