Допустим что сайт, которым пользуются ваши пользователи, написан на Joomla, но для создания нового продукта для вашей аудитории вы выбрали связку Python/Django.


Как следствие, возникает необходимость использовать в Django учетные записи пользователей из базы данных Joomla.


Проблема однако в том, что Joomla и Django используют разные алгоритмы хэширования паролей, поэтому просто скопировать учетные записи не получится.


Почитав документацию Django, stack overflow и потратив некоторое время, получилось нижеописанное решение, которое по максимуму использует рекомендуемые практики разработки под Django.


Предупреждения


Данное архитектурное решение может вам не подойти, см. обсуждение в комментариях.


Чтобы понимать, что происходит в нижеприведенных примерах, вы должны обладать некоторым пониманием архитектуры Django.


Также я предполагаю, что вы знаете, как развернуть Django проект, поэтому не описываю этот процесс.


Код скопирован из рабочего проекта но его легко будет подстроить под ваш проект с минимумом изменений.


Вероятно, в следующей мажорной версии Django данный код может поломаться, однако сам принцип решения останется тем же самым.


В данном руководстве я не описываю фронтэнд системы авторизации, так как:


  • какой у вас будет front-end — зависит от нужд вашего проекта (это может вообще быть Json API endpoint, например)
  • эта информация уже описана в официальных руководствах Django и разнообразных статьях для начинающих

Алгоритм


  • подключить базу данных (БД) Joomla к проекту Django
  • создать модель "JoomlaUser", представляющую пользователя из БД Joomla
  • написать функцию check_joomla_password(), проверяющую, что введенный пароль совпадает с оригинальным паролем пользователя.
  • добавить в проект новый бекенд авторизации "Joomla Auth Backend", который, при авторизации клиента в Django, будет доставать учетную запись пользователя из БД Joomla

1. Подключение к БД Joomla:


  • Прочитайте, как Django работает с несколькими базами данных
  • для подключения базы данных Joomla в наш Django проект, добавьте следующий код в файл с настройками проекта /project_name/settings.py:


    DATABASES = {
    # БД по умолчанию 
    'default': {
        ...
    },
    
    'joomla_db': {
        'ENGINE': 'django.db.backends.mysql',
        'OPTIONS': {},
        'NAME': 'joomla_database_name',
        # Don't store passwords in the code, instead use env vars:
        'USER':     os.environ['joomla_db_user'],
        'PASSWORD': os.environ['joomla_db_pass'],
        'HOST': 'joomla_db_host, can be localhost or remote IP',
        'PORT': '3306',
    }
    }


При необходимости, в этом же файле с настройками проекта, можно включить логирование запросов к БД:


# add logging to see DB requests:
LOGGING = {
    'version': 1,
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django.db.backends': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
    },
}

2. создайте модель JoomlaUser


  • Прочитайте, как модель Django может использовать существующую БД
  • Подумайте, где расположить новую модель "JoomlaUser".
    В моем проекте я создал application с именем "users" (manage.py startapp users). В ней будет лежать бекэнд авторизации и модель пользователя Joomla
  • сгенерируем модель автоматически, используя inspectdb:
    python manage.py inspectdb live_users --database="joomla_db"
    joomla_db — название БД, которое вы указали в settings.py/DATABASES;
    live_users — название таблицы c учетными записями.
  • добавьте вашу модель в users/models.py:


    class JoomlaUser(models.Model):
    """ Represents our customer from the legacy Joomla database. """
    
    username = models.CharField(max_length=150, primary_key=True)
    email = models.CharField(max_length=100)
    password = models.CharField(max_length=100)
    # you can copy more fields from `inspectdb` output, 
    # but it's enough for the example
    
    class Meta:
        # joomla db user table. WARNING, your case can differs.
        db_table = 'live_users'
        # readonly 
        managed = False
        # tip for the database router
        app_label = "joomla_users"  


Далее нам необходимо убедиться, что модель будет обращатся к правильной БД. Для этого добавим в проект роутер для запросов к разным БД, который будет перенаправлять запросы от модели JoomlaUser к её родной базе данных.


  1. Создайте файл "db_routers.py" в основной папке проекта (там же, где лежит ваш "settings.py"):


    # project_name/db_routers.py
    class DbRouter:
    """this router makes sure that django uses legacy 'Joomla' database for models,
    that are stored there (JoomlaUser)"""
    
    def db_for_read(self, model, **kwargs):
        if model._meta.app_label == 'joomla_user':
            return 'joomla_db'
        return None
    
    def db_for_write(self, model, **kwargs):
        if model._meta.app_label == 'joomla_user':
            return 'joomla_db'
        return None

  2. зарегистрируйте новый роутер в settings.py:


    # ensure that Joomla users are populated from the right database:
    DATABASE_ROUTERS = ['project_name.db_routers.DbRouter']


Теперь вы можете достать учетную запись из старой БД.
Запустите терминал Django и попробуйте вытащить существующего пользователя: python manage.py shell


>>> from users.models import JoomlaUser
>>> print(JoomlaUser.objects.get(username='someuser'))
JoomlaUser object (someusername)
>>> 

Если все работает (вы видите пользователя), то переходим к следующему шагу. Иначе смотрите на вывод ошибок и исправляйте настройки.


3. Проверка пароля учетной записи Joomla


Joomla не хранит пароли пользователей, но их хэш, например
$2y$10$aoZ4/bA7pe.QvjTU0R5.IeFGYrGag/THGvgKpoTk6bTz6XNkY0F2e


Начиная с версии Joomla v3.2, пароли пользователей зашифрованы с помощью алгоритма BLOWFISH.


Так что я загрузил python код с этим алгоритмом:


pip install bcrypt
echo bcrypt >> requirements.txt

И создал функцию для проверки паролей в файле users/backend.py:


def check_joomla_password(password, hashed):
    """
    Check if password matches the hashed password,
    using same hashing method (Blowfish) as Joomla >= 3.2

    If you get wrong results with this function, check that
    the Hash starts from prefix "$2y", otherwise it is 
    probably not a blowfish hash

    :return: True/False
    """
    import bcrypt
    if password is None:
        return False
    # bcrypt requires byte strings
    password = password.encode('utf-8')
    hashed = hashed.encode('utf-8')

    return hashed == bcrypt.hashpw(password, hashed)

Внимание! Joomla версии ниже чем 3.2 использует другой метод хеширования (md5+salt), так что эта функция не будет работать. В таком случае почитайте
обсуждение на Stackoverflow и создайте функцию для проверки хэша, которая будет выглядеть примерно так:


# WARNING - THIS FUNCTION WAS NOT TESTED WITH REAL JOOMLA USERS
# and definitely has some errors
def check_old_joomla_password(password, hashed):
    from hashlib import md5
    password = password.encode('utf-8')
    hashed = hashed.encode('utf-8')
    if password is None:
        return False

    # check carefully this part:
    hash, salt = hashed.split(':')
    return hash == md5(password+salt).hexdigest()

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


4. Бэкенд авторизации пользователей Joomla


Теперь вы готовы создать Django бэкенд для авторизации пользователей из Joomla проекта.


  1. прочитайте, как модифицировать систему авторизации Django


  2. Зарегистрируйте новый бэкенд (еще не существующий) в project/settings.py:


    AUTHENTICATION_BACKENDS = [
    # Check if user already in the local DB
    # by using default django users backend
    'django.contrib.auth.backends.ModelBackend',
    
    # If user was not found among django users,
    # use Joomla backend, which:
    #   - search for user in Joomla DB
    #   - check joomla user password
    #   - copy joomla user into Django user.
    'users.backend.JoomlaBackend',
    ]

  3. Создайте бэкенд авторизации пользователей Joomla в users/backend.py



from django.contrib.auth.models import User
from .models import JoomlaUser

def check_joomla_password(password, hashed):
    # this is a fuction, that we wrote before
    ...

class JoomlaBackend:
    """ authorize users against Joomla user records """
    def authenticate(self, request, username=None, password=None):
        """
        IF joomla user exists AND password is correct:
            create django user
            return user object 
        ELSE:
            return None
        """
        try:
            joomla_user = JoomlaUser.objects.get(username=username)
        except JoomlaUser.DoesNotExist:
            return None
        if check_joomla_password(password, joomla_user.password):
            # Password is correct, let's create and return Django user,
            # identical to Joomla user:

            # but before let's ensure there is no same username
            # in DB. That could happen, when user changed password
            # in Joomla, but Django doesn't know that
            User.objects.filter(username=username).delete()  

            return User.objects.create_user(
                username=username,
                email=joomla_user.email,
                password=password,
                # any additional fields from the Joomla user:
                ...
            )

    # this method is required to match Django Auth Backend interface
    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

Итог


Поздравляю — теперь пользователи вашего существующего Joomla сайта могут использовать свои учётные данные на новом сайте/приложении.


По мере авторизации активных пользователей через новый интерфейс, они будут по одному скопированы в новую базу данных.


Как вариант, вы можете не захотеть копировать сущности пользователей из старой системы в новую.


В таком случае вот вам ссылка на статью, в которой описывается, как в Django заменить модель пользователя по умолчанию на свою (вышеописанную модель JoomlaUser).


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


Тестирование и документация


Теперь пожалуйста добавьте соответствующие тесты и документацию, покрывающие новый код. Логика данного решения тесно переплетена с архитектурой Django и не очень очевидна, поэтому если вы не сделаете тесты/документацию сейчас, поддержка проекта в будущем усложнится.

Комментарии (13)


  1. Magikan
    05.03.2019 00:17

    У меня вопрос: зачем делать полный inspectdb, если для авторизации нужна ровно одна таблица. Думаю стоило бы упомянуть, что inspectdb может принимать 0+ имён таблиц на вход. Так же при тотальном переезде думаю имеет смысл запилить «DB R/W router» с небольшой доп обвязкой на уровне view чтобы чтение и запись производились из/в разные БД. Так и пользователей перенесете, и прочий необходимый контент.


    1. AcckiyGerman Автор
      05.03.2019 11:49

      1. спасибо за подсказку про inspectdb, добавил в статью
      2. в моем случае чтение и запись (пользователей) как раз в разные БД и просходит.
      При авторизации:
      — вначале ищем учетную запись в локальной БД Django (django.contrib.auth.backends.ModelBackend)
      — если user не найден (еще не скопирован из Joomla)
      — ищем и авторизуем в БД Joomla (самописный 'users.backend.JoomlaBackend')
      — создаем копию пользователя в БД Django (в JoomlaBackend: `return User.objects.create_user(...)`)
      > User тут — это встроенная модель `django.contrib.auth.models.User`

      При следующей авторизации пользователь уже есть в локальной БД.


  1. Tanner
    05.03.2019 00:28

    Мне кажется, давать доступ одному приложению к базе данных другого (работающего) приложения ? это плохая идея. Я бы в вашей ситуации авторизовал Django-пользователя в Joomla HTTP-запросом, при помощи requests, например.


    1. Magikan
      05.03.2019 00:41

      Плохая затея для сайта авторизовать пользователя на уровне кода.
      И read-only доступ к «старой» БД — по факту единственный и что более важно дешёвый вариант при большой миграции проекта. Тут главное доступ на запись отключить иначе фиаско братан )


      1. vooft
        05.03.2019 01:18

        А на уровне чего еще можно его авторизовать? Базы данных?


        1. Magikan
          05.03.2019 03:25

          я имел ввиду авторизацию вида http запроса к старому сайту


      1. Tanner
        05.03.2019 02:15

        Прошу прощения, что высказался не очень понятно. Разделять базу данных между несколькими приложениями ? это всегда плохая идея. Попробую показать, почему.

        1. Технически неоправданные зависимости. Если мы изменяем процедуру логина в одном приложении, нам наверняка нужно дорабатывать аналогичный кусок кода в другом приложении. Если мы апгрейдим СУБД для одного приложения, нам нужно убедиться, что это будет работать в другом приложении. И т. д.
        2. Параллельный доступ. Рано или поздно вы наткнётесь на проблемы с хронологией процессов в обоих приложениях.
        3. Производительность. Чем больше общих ресурсов, тем больше вероятность, что один из них станет бутылочным горлышком.
        4. Возможность апгрейда. Рано или поздно вам станет мало read-only-доступа: появится необходимость синхронизировать данные в профилях пользователей, например.

        Что же делать вместо общего доступа к БД? Воспользоваться API для доступа к данным. Есть несколько плагинов для Joomla, которые обеспечивают REST-сервисы. Если не все, то какие-то из них наверняка имеют возможность авторизовать клиента (в нашем случае, Django-приложение).

        В крайнем случае, если доработка Joomla совсем никак невозможна, то лучше авторизовывать Django-юзера на лету через джумловскую форму логина (тоже какой-никакой интерфейс).

        Это всё очень очевидные архитектурные вопросы, которые давно разжёваны. Вот пару источников, чтобы не быть голословным:

        www.ben-morris.com/a-shared-database-is-still-an-anti-pattern-no-matter-what-the-justification
        stackoverflow.com/q/3479297/10424832
        softwareengineering.stackexchange.com/q/105786


        1. Magikan
          05.03.2019 03:35

          на счёт шарить базу на несколько приложений в rw режиме — согласен, плохая затея (хотя и тут есть место для дискуссии). как я и говорил выше для миграции только read-only доступ к старой бд


        1. AcckiyGerman Автор
          05.03.2019 12:45

          Не могу согласится с вами полностью. Это очень неочевидный архитектурный вопрос, и даже в приведенных вами ссылках, мнения разделились, например:
          На stackoverflow:
          — 14 голосов за раздельные БД
          — 28 голосов за shared DB
          На stackexchange:
          — 39 голосов за DB-per-Application
          — 22 голоса за shared DB

          Я сам двумя руками за максимальную изоляцию сервисов, и мне хотелось сделать именно так, как вы советуете. Но в данном случае я решил использовать Read-only доступ к старой БД по нескольким причинам:
          1. новый сервис не подразумевает регистрации новых пользователей, только использование старых
          2. из этой же БД мне нужны другие данные, которых на данных момент 120Гб, они нужны часто и за большие периоды времени (по паре сотен Мб за раз), но тоже в Read-Only режиме. Использовать тут REST сервис значит создать бутылочное горлышко на пустом месте, а копировать read-only данные в новую БД не вижу смысла.
          3. я не знаю PHP в общем и Joomla в частности, и не уверен в своих возможностях прикрутить туда REST сервис и обеспечить его безопасность.
          4. (касательно только пользователей) я не нашел бесплатного oAuth2 модуля к Joomla с хорошей документацией, а без документации см. п. 3

          Кстати говоря, я до сих пор не уверен, что принял правильное решение, и как сильно нужно разделять или наоборот интегрировать эти два сервиса.

          Поэтому Magican Tanner, тут действительно есть место для дискуссии, и спасибо вам за участие в ней.


  1. mavriq
    05.03.2019 12:46

    а не подскажете что за dbrouter такой — manager.router.DatabaseAppsRouter?


    1. AcckiyGerman Автор
      05.03.2019 12:58

      о! Большое спасибо за замечание. Когда я писал статью, на этапе обобщения и выкидывания проекто-специфических кусков кода, я взял настройку DATABASE_ROUTERS из ответа на Stackoverflow, будучи в святой уверенности, что «manager.router.DatabaseAppsRouter» это встроенный в Django роутер. Это оказалось неверным и уже путь импорта, начинающийся не с «django» должен был меня насторожить.

      Сейчас исправлю статью.


      1. mavriq
        06.03.2019 17:44

        В таком случае, если вы не против моей "саморекламы", в этой ветке оставлю ссылку на свое простенькое решение для подобного случая: https://pypi.org/project/django-dbrouter/


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


        PS
        тем более, ваш вариант не обрабатывает такие важные методы, как allow_relation и allow_migrate
        Таким образом, при manage.py makemigrations выможете получить попытку писать в эту БД (и хорошо, если она доступна лишь на чтение)


        1. AcckiyGerman Автор
          07.03.2019 12:31

          Модуль как модуль, описание понятное, код очевидный. Если будет развиватся — добавьте тестов по вкусу.
          Мне было проще скопировать десяток строчек из документации, чем тащить дополнительную зависимость, но если баз данных много, то такой модуль пригодится.

          P.S. Методы allow_relation и allow_migrate не так уж важны:
          1. Насколько я знаю, Django до сих пор не поддерживает отношения (foreign key) между разными БД, так что `allow_relation` это просто задел на будущее Django
          2. `allow_migrate` это тоже перестраховка в Django. Этот метод не препятствует `makemigrations` создавать миграции, но `./manage.py migrate` опрашивает его, чтобы пропускать ReadOnly БД.
          Но в Django есть еще уровни защиты:
          — вывод `inspectdb` по умолчанию содержит `managed=False` (читай — read only) для всех генерируемых моделей
          — `migrate` по умолчанию применяется только к `default` БД

          Я думаю если программист руками исправил `managed` на `True` и потом запускает `migrate --database=some_non_default_db` то он явно хочет чтото мигрировать, и еще один триггер в DB Router его уже не остановит.

          `allow_migrate` скорее пригодится в конфигурации, где баз данных больше чем две, и некоторые из них read_only а некоторые — нет.

          Но конечно лучше всего резать доступ уже на уровне access_rights пользователя БД.