Мои контакты


среда, 5 июля 2017 г.

Разработка SaaS на Django и Python. Часть 1. Поддомены и публичный API


Вдохновившись интересными решениями на последних наших проектах, я решил описать то, как можно реализовать три типичные составляющие SaaS проекта на Django и Python.

Материал будет разбит на две части, в которых мы рассмотрим следующие темы.
  1. Размещение каждого аккаунта на отдельном поддомене; 
  2. Простой и быстрый способ выделить публичный API вашего сервиса; 
  3. Размещение данных каждого аккаунта в отдельных базах данных.
Сегодня речь пойдет о первых двух пунктах. Рассказ о размещении аккаунтов в разных базах данных заслуживает отдельной статьи.

На сервере мы часто используем Django и Python, Celery и Redis для реализации распределенной очереди задач, а в качестве СУБД PostgreSQL или MySQL. Клиентская сторона обычно реализуется на AngularJS, ReactJS или другом MVVM/MVC фреймворке.

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

1. Отдельные поддомены 


Обычно SaaS-сервисы размещают своих клиентов на отдельных поддоменах. Каких-то особых преимуществ с точки зрения архитектуры это не дает, зато у пользователей появляется возможность использовать название их компании в строке браузера. Это приятно выглядит, а так же выделяет портал как точку входа именно для пользователей конкретной компании.

О том, как работать с поддоменами в Django мы уже давно рассказывали в этом блоге: http://blog.antidasoftware.com/2015/12/django.html.

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

2. Публичный API


Так как фронтенд построен на MVVM/MVC фреймворке, то серверная сторона должна быть реализована в виде WebAPI.

WebAPI можно создавать по-разному. За основу можно взять Django REST Framework и на базе него построить API. Это правильный путь, если вам нужен именно REST API с поддержкой OAuth, или вы начинаете проект с нуля.

Но что делать, если вам нужно вынести наружу часть своего внутренного API в обычном приложении Django, с которым уже сейчас работает, например, AngularJS? Или если вам по каким-то причинам не подходит REST API или Django REST Framework? Хакнуть Решить проблему можно достаточно интересно и просто.

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

Определим ключ API для каждого пользователя.

class AccountUser(AbstractEmailUser):
   ...
   api_token = models.CharField(max_length=64, default=generate_api_token)
   ...

Теперь нам нужно написать кастомный backend для аутентификации пользователей по токену. Его задача заключается лишь в том, чтобы найти в базе данных пользователя с соответствующим api_token. Подробнее про аутентификацию в Django и возможности ее расширения: https://docs.djangoproject.com/en/1.11/topics/auth/.

class ApiTokenBackend(object):
    def authenticate(self, api_token=None):
        if not api_token:
            return None

        try:
            return AccountUser.objects.get(api_token=api_token)
        except AccountUser.DoesNotExist:
            return None

    def get_user(self, user_id):
        try:
            return AccountUser.objects.get(id=user_id)
        except AccountUser.DoesNotExist:
            return None

Добавим ApiTokenBackend к списку бекендов для аутентификации в settings.py:
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'path.to.ApiTokenBackend'
]

Django будет использовать два бекенда для аутентификации: ModelBackend — по email и ApiTokenBackend — по токену.

Теперь давайте определимся, что наш публичный API будет доступен на поддомене api. Далее изменим ход обработки запроса так, чтобы Django аутентифицировал пользователей по токену, если это запрос отправлен на поддомен api и в GET-параметрах запроса передан ключ api_token. Напишем соответствующий обработчик в слой Middleware.

class ApiTokenAuthMiddleware(object):
    def process_request(self, request):
        if not hasattr(request, 'subdomain') or request.subdomain != 'api':
            return

        token = request.GET.get('api_token', None)
        if not token:
            return

        try:
           user = authenticate(api_token=token)
           if user:
              request.user = user
        except AccountUser.DoesNotExist:
            return None


Отлично. Функция authenticate будет использовать ApiTokenBackend для аутентификации. Теперь запросы к публичному API будут ассоциироваться с определенным пользователем. Однако, у нас есть одна проблема с POST-запросами: они не будут корректно обрабатываться из-за проверки CSRF токена. Исправим это через другой компонент Middleware.

class ApiTokenCsrfExemptViewMiddleware(object):
    def process_view(self, request, callback, callback_args, callback_kwargs):
        if hasattr(request, 'subdomain') and request.subdomain == 'api':
            setattr(callback, 'csrf_exempt', True)


Теперь для POST-запросов к публичному API не будет выполняться проверка CSRF токена.

Слой Middleware компонентов теперь должен выглядеть как-то так.

MIDDLEWARE_CLASSES = [
    ...

    'subdomains.middleware.SubdomainURLRoutingMiddleware',
    'corsheaders.middleware.CorsMiddleware',

    ...

    'path.to.middleware.ApiTokenCsrfExemptViewMiddleware',

    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
    ...

    'path.to.middleware.ApiTokenAuthMiddleware'
]

Как можно заметить, у нас остался один компонент, который мы пока не упомянули: CorsMiddleware. Это часть библиотеки django-cors-headers, которая используется для управления заголовками Access-Control-Allow-Origin в ответе от сервера.

Стандартный механизм обработки запросов Django будет запрещать любые запросы с доменов, отличных от того, на котором работает сам сервер приложения. Нам нужно настроить политику сервера таким образом, чтобы запросы на поддомен api были разрешены всегда.

Подробнее о CORS: https://ru.wikipedia.org/wiki/Cross-origin_resource_sharing.

Устанавливаем django-cors-headers и создаем файл signals.py в нужном приложении со следующим содержимым:

from corsheaders.signals import check_request_enabled


def cors_allowed(sender, request, **kwargs):
    return hasattr(request, 'subdomain') and request.subdomain == 'api'


check_request_enabled.connect(cors_allowed)

В apps.py пишем:

class MyAppConfig(AppConfig):
    name = 'app_name'

    def ready(self):
        from . import signals

Тем самым мы гарантируем, что обработчик сигнала для проверки запроса на CORS будет зарегистрирован.

Отлично. Нам остается лишь настроить urlconf таким образом, чтобы запросы на поддомен api доходили до views.

Сначала мы можем поступить по-простому и открыть доступ ко всем внутренним endpoint'ам, которые использует ваше приложение.

ROOT_URLCONF = 'my_api_app.urls'
SUBDOMAIN_URLCONFS = {
    None: 'marketing_website.urls',
    'api': 'myapp.urls'
}

Это достаточно плохое решение, т.к. теперь пользователям публичного API становятся доступны все внутренние endpoint'ы, среди которых могут быть критичные. Поэтому нам нужно построить гибкий urlconf, в котором мы смогли явно задавать какой из endpoint'ов доступен для внутренного API, а какой для внешнего.

Создайте пакет urls, внутри которого создайте еще один пакет api с файлами private.py и public.py, которые будут определять список доступных endpoint'ов во внутреннем и публичном API соответственно.

Содержание этих файлов будет примерно такое:

from django.conf.urls import url
from myapp.api.users import UsersListApiView

urlpatterns = [
    url(r'^users/list$', UsersListApiView.as_view(), name='api-users-list'),
    ...
]

Добавляя или убирая из этих файлов endpoint'ы, мы можем определять какие методы будут доступны в публичном и внутреннем API.

Теперь добавим в модуль urls файлы app_urls.py и public_api_urls.py. Первый будет определять urlconf для приложения, а второй только для публичного API.

Файл app_urls.py:

from django.conf.urls import url, include

urlpatterns = [
    url(r'^v1/', include('myapp.urls.api.public'))
]

Файл public_api_urls.py:

from django.conf.urls import url, include

urlpatterns = [
    ...
    url(r'^api/v1/', include('myapp.urls.api.private')),
    ...
]

Файловая структура получится примерно следующая:


Теперь изменим urlconf в файле settings.py следующим образом:

ROOT_URLCONF = 'myapp.urls.app_urls'
SUBDOMAIN_URLCONFS = {
    None: 'marketing_website.urls',
    'api': 'myapp.urls.public_api_urls'
}

Готово. Теперь внутренний API будет доступен на https://subdomain.myservice.com/api/v1/, а публичный на https://api.myservice.com/v1/. Соответственно, в первом случае у вас будет работать аутентификация через сессию, а во втором случае через api_token.

Решение рабочее, простое в реализации и в большинстве случаев этого будет вполне достаточно, но если вы начинаете проект с нуля, рекомендую смотреть в сторону Django REST Framework.

Продолжение: шардинг базы данных.