Привет, Хабр! Меня зовут Вячеслав Разводов, и я уже более 10 лет в IT. За это время мне удалось пройти путь от разработки на Delphi, разработки веб-сайтов на PHP-фреймворках до backend-разработки на Python. Этот материал является результатом моих усилий по систематизации знаний об SSO (единой системе идентификации).

В моей карьере присутствует период работы в бюджетной организации, подчиняющейся Министерству здравоохранения, где моя задача - разрабатывать информационные системы в формате веб-приложений. Условно системы можно сгруппировать:

  • регистры для специфических заболеваний;

  • инструменты для сбора статистических данных от медицинских работников;

  • системы для обработки отчетности.

Разработка новых, специализированных систем была регулярной задачей, с новыми проектами почти каждую неделю. Обычное техническое задание выглядело следующим образом:

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

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

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

В таких случаях, решением проблемы служит технология Единого Входа (SSO - Single Sign-On). Технология предоставляет возможность пользователям получать доступ к разным системам без повторного ввода логина и пароля. Например, врачу не нужно проходить процедуру авторизации в каждом отдельном веб-приложении при переключении между ними. Как это работает? И использовать такую технологию?

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

Основы аутентификации и авторизации в Django

Отвечаем на вопрос “Ты кто?” (аутентификация в Django)

Если спросить google ответ будет следующим:

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

Переведем на человеческий язык:

Аутентификация — это когда система сверяет предоставленные учетные данные, к примеру логин и пароль, с данными которые она хранит. В отделении банка, перед предоставлением вам услуг, работник просит предъявить ваш паспорт. После он внимательно сверяет ваше фото и лицо. Лицо и паспорт - это учетные данные, а процесс проверки сотрудником банка - аутентификация вас как клиента банка.

Аутентификация является важным элементом любой информационной системы, потому что:

  1. Безопасность и Конфиденциальность: Аутентификация является ключевым элементом защиты, препятствующим несанкционированному доступу к системам и данным. Она обеспечивает, что доступ к информации будет разрешен только авторизованным пользователям, тем самым предотвращая утечку конфиденциальных данных.

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

  3. Контроль Доступа и Авторизация: Аутентификация лежит в основе процесса авторизации, который устанавливает доступность определенных ресурсов и действий для аутентифицированных пользователей.

В Django система аутентификации включает:

  • User Model: В фреймворке Django присуствует стандартная модель пользователя, которая содержит поля и методы, необходимые для управления учетными записями пользователей.

  • Middleware: Django использует middleware для обработки запросов и ответов. Middleware аутентификации добавляет пользователя к каждому запросу.

  • Views: Django предоставляет встроенные views для входа и выхода из системы, изменения и восстановления пароля.

Ключевую роль в процессе аутентификации играют - authentication backends. Authentication backends - это компоненты, которые определяют, каким образом происходит проверка учетных данных пользователя.

Django позволяет использовать несколько бэкендов аутентификации одновременно. Если стандартные методы аутентификации не удолетворяют требования разработчика. Можно написать собственный бэкенд. Для этого нужно создать класс, который наследует от django.contrib.auth.backends.BaseBackend и реализует методы authenticate (для проверки учетных данных) и get_user (для получения объекта пользователя).

Использование authentication backends в Django позволяет гибко подходить к процессу проверки подлинности пользователей, обеспечивая безопасность данных пользователей и удобство для разработчиков приложения.

Отвечаем на вопрос “Какие действия тебе доступны?” (авторизация в Django)

Авторизация — это процесс, который следует после аутентификации и определяет, какие действия или ресурсы доступны пользователю в системе. То есть это механизм, который определяет права и ограничения действий пользователей в системе. Выделим основные направления ее применения.

В первую очередь, как я уже отмечал, контроль доступа к ресурсам и ограничения действий пользователя. Контроль доступа к ресурсам - система будет проверять к каким файлам, страницам, функция имеет доступ пользователь на основе уровня прав. Ограничения действий, подразумевает какие операции (такие, как чтение, запись, удаление) разрешены пользователю на определенных ресурсах. Оба понятия близки по смыслу, поэтому я объединил их в одном пункте.

Второе направление применения авторизации - защита от несанкционированного доступа и соблюдение политик безопасности. Очевидно что с авторизацией в системе — никто не сможет получить доступ к защищённым ресурсам без ее прохождения.

Последнее направление — это трассировка и аудит. После авторизации пользователя система может вести журнал действий пользователя. Например, после авторизации в интернет-магазине, будут записываться все обращения к каталогу товаров, чтобы делать наиболее релевантные рекомендации.

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

Рассмотрим виды контроля доступа. Отмечу, что я расположил в условном порядке от меньшего к большему, для упрощения усвоения информации.

Контроль доступа по атрибутам ABAC (Attribute-Based Access Control):

Доступ определяется на основе атрибутов пользователя, ресурса и окружающей среды. Django не включал в себя встроенную систему управления доступом на основе атрибутов ABAC как часть своего стандартного набора функций. Однако, благодаря гибкости и расширяемости Django, можно интегрировать ABAC, с помощью используя сторонние пакеты или разрабатывая собственное решение.

Примером стороннего решения является - django-rules. Собственные решения разрабатываются с применением паттерна декоратор. С алгоритмом в котором учитывают различные атрибуты и свойства для принятия решений о доступе к данным.

Контроль доступ по роли пользователя RBAC (Role-Based Access Control):

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

Создания группы и присвоения разрешений:

from django.contrib.auth.models import Group, Permission

# Создание группы
editor_group, created = Group.objects.get_or_create(name='Редакторы')

# Получение разрешения
permission = Permission.objects.get(codename='change_article', name='Разрешено редактировать статьи')

# Присвоение разрешения группе
editor_group.permissions.add(permission)

Проверка разрешений во View:

from django.contrib.auth.decorators import permission_required

@permission_required('app_name.change_article')
def edit_article(request, article_id):
    # Логика обработки

Проверка разрешений в шаблонах:

{% if perms.app_name.change_article %}
  <a href="{% url 'edit-article' article.id %}">Edit Article</a>
{% endif %}

Контроль доступа на уровне объекта DAC (Discretionary Access Control):

Контроль доступа на уровне объекта (Object-Level Permissions) позволяет устанавливать дополнительные разрешения на конкретные объекты, с учетом общей политики безопасности. Например, автор статьи может иметь право редактировать свою статью, но не статьи других пользователей. В Django нет встроенной поддержки контроля доступа на уровне объекта для моделей, но можно реализовать это самостоятельно.

Добавляем метод в модель:

from django.contrib.auth.models import User

class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)

    def can_edit(self, user: User):
        return self.author == user

Проверьте разрешение во View:

from django.http import HttpResponseForbidden

def edit_article(request, article_id):
    article = Article.objects.get(pk=article_id)
    
    if not article.can_edit(request.user):
        return HttpResponseForbidden("You don't have permission to edit this article.")

    # Логика редактирования статьи

Контроль доступа, при котором Владелец ресурса определяет политику доступа MAC (Mandatory Access Control):

Mandatory Access Control (MAC) – это система безопасности, где правила доступа к файлам и программам устанавливаются централизованно и пользователи не могут их изменять. MAC - строгий охранник, который следит за тем, чтобы каждый имел доступ только к тому, что разрешено высшими инструкциями, даже если владелец файла хочет дать кому-то доступ. Django не имеет встроенной поддержки для модели Mandatory Access Control в классическом понимании.

Что такое SSO и с чем его едят?

SSO (Single Sign-On) представляет собой технологическое решение в области безопасности и идентификации, которое позволяет пользователям входить в различные приложения и сервисы, используя только один набор учетных данных. Вместо того чтобы помнить пары логин, пароль для каждого отдельного приложения или веб-сервиса, пользователь может использовать одно имя пользователя и пароль для доступа к нескольким ресурсам.

Как это работает?

Технология SSO стоит на 2 китах. Первый кит - провайдер, это сервис который проводит процесс аутенфикации пользователя. Второй кит, или киты - это потребители, то есть приложения которые будут авторизовать пользователя. В классическом виде, это работает следующим образом:

  1. Пользователь пытается получить доступ к приложению. Приложение отправляет пользователя на страницу входа от провайдера.

  2. Провайдер проводит аутенфикацию пользователя, и возвращает приложению токен. В данном случаем под токеном подразумевается набор данных. В зависимости от протокола это может JWT или xml.

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

Использование технологии SSO, улучшает пользовательский опыт. Пользователям не нужно запоминать множество паролей для различных систем и приложений, что уменьшает сложность взаимодействия с сервисами. Вход в систему требуется только один раз, это экономит время пользователей. Снижается риска случая “Забыл Пароль”, потому пользователю нужно помнить только один пароль.

Административная панель провайдера SSO предлагает удобный способ эффективного управления всеми аспектами безопасности. Используя интерфейс панели, администратор имеет возможность реализовать ряд ключевых задач: от восстановления паролей пользователей до контроля их доступа к связанным системам, а также анализа пользовательской активности.

Где применяется?

Интеграция между различными веб-приложениями предоставляет пользователям удобный доступ к различным сервисам без повторной аутентификации. Это включает офисные инструменты, социальные сети и медиа, e-Commerce и образовательные ресурсы.

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

SSO на практике Keycloak

Разбираемся с протоколами SSO

Существует множество протоколов SSO, но давайте рассмотрим два наиболее популярных: OAuth2 и SAML. OAuth 2.0 - это открытый стандарт авторизации, предназначенный для предоставления третьим лицам ограниченного доступа к защищенным ресурсам пользователя. Провайдер Keycloak поддерживает OpenID Connect (OIDC) который является расширением протокола OAuth 2.0. SAML (Security Assertion Markup Language) - это стандарт обмена данными аутентификации и авторизации, основанный на XML.

OAuth 2.0 (OpenID Connect )

SAML (Security Assertion Markup Language)

Область применения

Веб, мобильные и настольные приложения.

Веб-приложения, особенно в корпоративных средах.

Преимущества

Гибкость, широко распространен, поддерживает различные типы токенов.

Поддерживает одиночный вход (Single Sign-On), безопасен, самовыполняемый (не требует внешних запросов для аутентификации).

Использование

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

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

OAuth 2.0 обладает лучшей масштабируемостью и гибкостью благодаря своему простому API и поддержке различных типов токенов. Но SAML предлагает более высокий уровень защиты за счет использования XML и цифровых подписей.

Выбор стандарта зависит от конкретных сценариев использования: OAuth 2.0 подходит для мобильных и настольных приложений, в то время как SAML лучше всего подходит для корпоративных веб-приложений.

Разворачиваем Keycloak (провайдер SSO)

В качестве провайдера SSO будем использовать Keycloak. Keycloak - это открытое программное обеспечение для управления и удостоверением личности. Он предоставляет надежное и гибкое решение для аутентификации, авторизации и управления правами доступа пользователей в приложениях и сервисах. Keycloak поддерживает множество протоколов аутентификации, включая SAML и OpenID Connect, что делает его мощным инструментом для реализации единого входа SSO.

Запускать Keycloak будем в Docker, для этого создаем файл docker-compose.yaml.

# deploy/docker-compose.yaml
version: '3.1'

services:
  keycloak:
    image: jboss/keycloak # *для ARM: sleighzy/keycloak:16.1.0-arm64
    environment:
      KEYCLOAK_USER: admin_key
      KEYCLOAK_PASSWORD: admin_key
    ports:
      - "8080:8080"

*Образ sleighzy/keycloak:16.1.0-arm64 - это не официальная сборка, я использовал его для запуска Keycloak на Macbook Pro на M2. Рекомендую использовать официальный образ jboss/keycloak.

После запуска контейнера, зайдем в панели управления, открыв в веб-браузере следующий URL (http://localhost:8080/auth) (рис. 1 ).

Рис.1. Приветственная страница Keycloak
Рис.1. Приветственная страница Keycloak

Выбираем “Administration Console” и попадаем на страницу авторизации в административной панели. Вводим логин/пароль который указывали в директивах KEYCLOAK_USER/KEYCLOAK_PASSWORD.

Создадим отдельный Realm для нашей тестовой интеграции с Django. Realm — это пространство, которое включает в себя пользователей, клиентов, роли и другие конфигурационные элементы. Выберите меню "Master" Realm, и нажмите "Add Realm" (рис. 2).

Рис. 2. Меню добавления нового Realm
Рис. 2. Меню добавления нового Realm

В открывшемся окне, заполняем поле “Name” - название нового Realm (рис. 3). В моем случае я назову его “Django Integration”.

Рис. 3. Создаем новый Ream
Рис. 3. Создаем новый Ream

После создания Realm, переходим на страницу добавления Clients (рис.4). Клиенты представляют собой приложения, которые будут использовать Keycloak для аутентификации.

Рис.4. Страница Clients
Рис.4. Страница Clients

На странице создания клиента (рис. 5) в поле Client ID, вводите произвольное название клиента. НО запомните его, оно понадобится дальнейших шагах.

Рис. 5. Страница создания клиента
Рис. 5. Страница создания клиента

В списке Client Protocol - выбираем openid-connect, как упоминалось ранее, это протокол основанный на OAuth 2.0.

Root URL для клиента в Keycloak - это базовый URL, который используется при формировании абсолютных URL в аутентификационных запросах и ответах. В моем случае, это локально запущенная Django на порту 8010. Этот параметр критически важен для обеспечения того, чтобы переадресации, инициированные Keycloak, корректно вели к нужным точкам входа вашего приложения.

Вот зачем это нужно:

  1. Формирование URL в аутентификации:

    Когда Keycloak перенаправляет пользователя на страницу аутентификации, он должен указать точный URL-адрес, на который пользователь будет возвращен после успешной аутентификации. Этот URL формируется на основе Root URL.

  2. Управление перенаправлениями:

    Root URL также используется для определения, на какой URL будет перенаправлен пользователь в случае успешной аутентификации или в случае ошибки. Например, если пользователь нажимает "Забыли пароль?" и запрашивает сброс пароля, Keycloak будет использовать Root URL для определения, куда перенаправить пользователя после этой операции.

  3. Обработка перенаправлений в клиентской стороне:

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

Допустим, пользователь пытается получить доступ к нашему сайту, но не прошел аутентификацию. В этом случае приложение перенаправит его на Keycloak для аутентификации. После успешного прохождения аутентификации, Keycloak перенаправит пользователя обратно на наш сайт, вместе с токеном. Root URL в этом контексте определяет конкретный URL-адрес на нашем сайте, куда будет направлен токен.

После добавления Client, нас перенаправляет на страницу настройки Client (рис. 6).

Рис. 6. Страница настроек Client
Рис. 6. Страница настроек Client

В настройке Client нужно выбрать Access Type. Keycloak предлагает три различных режима "Access Type" для клиентов:

  1. Public:

    • В этом режиме клиент считается "публичным" и не имеет секретного ключа.

    • Клиент не может обмениваться кодами авторизации на токены безопасности, так как он не имеет секрета для подписи запросов.

    • Однако он может быть использован для аутентификации пользователей внутри браузера, например, в SPA-приложениях.

  2. Confidential:

    • В этом режиме клиент считается "конфиденциальным" и имеет секретный ключ.

    • Клиент может обмениваться кодами авторизации на токены безопасности с использованием своего секрета.

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

  3. Bearer-only:

    • Клиент считается "только-для-носителей" и не способен обмениваться кодами авторизации.

    • В этом режиме клиент принимает только аутентификационные токены в заголовках HTTP запросов.

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

Выбор режима "Access Type" зависит от типа вашего клиента и какие действия он должен выполнять в системе. Например, веб-приложение может использовать "Confidential" для обмена кодами авторизации, тогда как API, возможно, будет настроен как "Bearer-only", ожидая токены в заголовках запросов.

Реализация интеграции на стороне Django, непосредственно зависит от выбора режима "Access Type".

Реализация интеграции на стороне Django, непосредственно зависит от выбора режима "Access Type".

Confidential:

При этом режиме при входе на наш сайт, пользователь редиректится на страницу входа от Keycloak. После успешной аутенфикации, возвращается на Valid Redirect URLs с токеном, по которому бэкенд сайта сможет получить данные пользователя. Необходим когда сайт, доступен в Интернете.

Bearer-only

При это режиме, данные пользователя уже доступны в Headers запроса пользователя. В данном случае можно RemoteUserBackend. RemoteUserBackend предназначен для аутентификации пользователей с использованием HTTP заголовков, предоставляемых веб-сервером, таких как REMOTE_USER. Такой вариант является, не безопасным, но идеально подойдет для корпоративных систем, когда доступ к сервисам осуществляется в рамках закрытой сети.

В поле Valid Redirect URLs изменяем на http://localhost:8010/keycloak_callback/.

Нажимаем кнопку “Save” и после сохранения рядом с вкладкой “Settings” появится вкладка “Credentials”. Если выбираем Confidential в Access Type, из вкладки “Credentials” необходимо копировать значение поля Secret. Этот Secret клиента необходимо будет сохранить в бэкенде приложения.

Переходим, в раздел Users чтобы добавить пользователя.

Рис. 7. Добавление пользователя
Рис. 7. Добавление пользователя

Вводим данные учетной записи пользователя и пароль.

Рис. 8. Добавление нового пользователя
Рис. 8. Добавление нового пользователя

Нажимаем кнопку “Save”, и переходим во вкладку Credentials, чтобы задать пароль.

Рис. 9. Задание пароля в данных пользователя
Рис. 9. Задание пароля в данных пользователя

Keycloak провайдер готов к использованию. Теперь можно приступить к разработке интеграций с бэкендом на Django.

Интеграция SSO с Django

Хочу подчеркнуть, что приведенные ниже примеры предназначены исключительно для лучшего понимания внутреннего механизма интеграции Keycloak и Django. В реальных проектах рекомендуется использовать библиотеку django_keycloak. В данной статье не рассматривается применение этой библиотеки.

Описание взаимодействия Django и Keycloak

Прежде чем, переходить к созданию интеграции между Django и Keycloak, предлагаю обсудить план действий. В виде схемы алгоритм взаимодействия отображен на рис.10.

Рис. 10. Схема взаимодействия между сайтом на Django и Keycloak
Рис. 10. Схема взаимодействия между сайтом на Django и Keycloak

Шаг 1. Не авторизованный пользователь попадает на страницу /login. Здесь будет только 1 кнопка, “???? Войти SSO”. Кнопка ведет на внутренний url /keycloak_login/.

Шаг 2. В view keycloak_login возвращаем браузеру пользователя редирект, на форму авторизации Keycloak. В GET-параметрах передаем client_id - произвольное имя клиента, которое задавали при создание client (рис. 5). И response_type, в нашем случае это code. Отдельно хочу отметить что такое response_type и почему code.

Если не передаем параметр, response_type - то в ответе получаем ошибку "Missing parameter: response_type”. В OpenID Connect response_type определяет, какой тип ответа вы ожидаете от сервера аутентификации. Вспоминаем, что OpenID Connect это расширенный протокол Auth 2.0. Одной из отличительных особенностей заключается, в том что в OpenID Connect, добавляется дополнительный шаг в работе алгоритма. Когда вместо access token, сначала провайдер SSO возвращает клиенту code. В обмен на который у провайдера SSO клиент получается access token. Возможно есть еще различия, но этот я считаю ключевым.

Так вот, используя response_type клиент может выбрать вариант "потока авторизации" (Authorization Flow). OpenID Connect предоставляет несколько потоков (flows) авторизации, каждый из которых предназначен для различных сценариев использования. Вот краткое описание трех основных потоков:

  • Authorization Code Flow (Авторизационный код):

    • response_type: "code"

    • Этот поток предполагает, что клиент может сохранить секрет, например, в безопасной среде сервера. Сначала пользователь перенаправляется на страницу аутентификации Keycloak, где вводит учетные данные. После успешной аутентификации Keycloak выдаст клиенту временный код. Затем клиент обменивает этот код на токены (Access Token, ID Token) на защищенном канале с использованием своего секрета.

  • Implicit Flow (Неявный):

    • response_type: "token"

    • В этом потоке токены передаются непосредственно в браузер пользователя после успешной аутентификации, без необходимости обмена кода. Этот поток обычно используется в SPA (Single Page Applications), где сохранение секрета на клиенте считается менее безопасным. Однако, поскольку токены передаются в URL-адресе, это может представлять риск безопасности.

  • Hybrid Flow (Гибридный):

    • response_type: Комбинация "code" и "token" или "code" и "id_token" или все три: "code id_token token"

    • Этот поток предоставляет гибкость комбинирования различных типов токенов в зависимости от потребностей приложения. Например, можно получить код для обмена на Access Token и ID Token, а также получить Access Token напрямую.

В текущий реализации будем использовать Authorization Code Flow. В зависимости от выбранного варианта, реализация незначительно различается.

Шаг 3. Получаем ответ Keycloak после того, как пользователь успешно прошел аутентификацию у провайдера SSO, т. е. успешно прошел проверку учетных данных в Keycloak. После аутентификацию Keycloak, сделает редирект на заданный в настройках client url, в данном случае /keycloak_callback/.

Вот так выглядит url при редиректе. В GET-параметре есть code - который, нужно обменять у провайдера на токен.

/keycloak_callback/?session_state=64287c32-4376-4043-9e57-3c1c76121580&code=837079ea-d2c2-4982-8ed1-32c699a20ef8.64287c32-4376-4043-9e57-3c1c76121580.80ea65cf-cf0f-4ae3-97fd-18f714ecf078

Шаг 4. Получаем полученный code вместе client_secret - его можно получить “Credentials” поле Secret на странице редактирование Client.

Шаг 5. Полученный access_token можем декодировать с помощью библиотеки jwt. Если декодирование успешное, то считаем токен валидным, так как при декодировании используем публичный ключ полученный из Keyclock. В реальном проекте, можно добавить дополнительные варианты валидации токена, но здесь ограничимся декодирование и проверкой подписи публичного ключа при декодировании.

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

Переходим к реализации в коде

Создаем приложение auth_sso в рамках, в нем у нас будет бизнес-логика связанная с темой этой статьи. Для этого выполняем команду:

# команду выподняем в корени проекта, где развернут django
python manage.py startapp auth_sso

В настройках settings.py проекта добавим константы с данными Keycloak и регистрируем приложение:

# django_sso/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'auth_sso', # добавляем название созданого приложения
]

...


CLIENT_ID = "django-sso" # произвольное название Client задается при созданиие Client
CLIENT_SECRET = "PAZsKiK1tt4bCmz6CUzSKDWttFU5rZyD" # на странице редактирование Client вкладка Credentials поле Secret
REALM_NAME = "Django Integration" # задается при создании Realm
KEYCLOAK_URL_BASE = "http://localhost:8080/auth/" # базой url Keycloak
KEYCLOAK_AUDIENCE = "account" # область Client - про это поговорим ниже
KEYCLOAK_IS_CREATE = 1 # флаг управляет, логикой что если пользователь не найден, то создаем

При желании, управлять значениями этих констант можно через переменные окружения.

Регистрируем роутинги приложения auth_sso.

# django_sso/urls.py
from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path('', include('auth_sso.urls')), # добавялем для регистрации роутингов приложения
    path('admin/', admin.site.urls),
]

В файле auth_sso/urls.py региструем 4 пути, о которые описывались выше в алгоритме.

urlpatterns = [
    path('', home_page, name='home_page'), # основная страница
    path('login/', login_page, name='login_page'), # страница к кнопкой для инцилизации логина
    path('keycloak_login/', keycloak_login, name='keycloak_login'), # view которое делает редирект на страницу входа Keycloak
    path('keycloak_callback/', keycloak_callback, name='keycloak_callback'), # view обрабатывает ответ Keycloak, валидирует токен авторизует пользователя
]

View для login и keycloak_login простые по своей сути, и не требую дополнительным комментариев.

from django.conf import settings
from django.shortcuts import render, redirect


def login_page(request):
    if request.user:
            return redirect('/')
    return render(request, 'auth_sso/index.html')


def keycloak_login(request):
   """
   Перенаправление пользователя на страницу аутентификации Keycloak.

   settings.KEYCLOAK_URL_BASE - путь до Keycloak c доменом
   settings.REALM_NAME - это название пространства Realm, задает при настройке Keycloak
   settings.CLIENT_ID - клиент относительно Keycloak это наше приложение
   """
   redirect_url = f"{settings.KEYCLOAK_URL_BASE}realms/{settings.REALM_NAME}/protocol/openid-connect/auth" \
                  f"?client_id={settings.CLIENT_ID}&response_type=code"

   return redirect(redirect_url)

Откроем url http://localhost:8010/login/ в браузере (у меня приложение запущено локально на порту 8010). Откроется страница авторизации (рис. 11).

Рис. 11. Страница логина на проекте
Рис. 11. Страница логина на проекте

После клика на кнопку “Войти SSO”, происходит редирект на Keycloak описанный на Шаге 2.

Рис. 12. Страница авторизации от Keycloak
Рис. 12. Страница авторизации от Keycloak

Если учетные данные валидные, после отправки формы с рис.12 под капотом выполняется с 3 по 6 Шаги описанные в алгоритме. На взгляд пользователя, он сразу перекидывается на внутреннею страницу (рис. 13).

Рис. 13. Внутренняя страница проекта доступная авторизованным пользователям
Рис. 13. Внутренняя страница проекта доступная авторизованным пользователям

Теперь разберемся, как это работает под капотом. Начнем с view keycloak_callback - в котором выполняется основная логика. Как я описывал раньше, Keycloak при редиректите на url /keycloak_callback/ указывает session_state - идентификатор сессии, и code авторизационный код.

# auth_sso/views.py
from django.contrib.auth.decorators import login_required
from auth_sso.backends import KeycloakConfidentialBackend


@login_required(redirect_field_name='next', login_url='/login/')
def home_page(request):
    return render(request, 'auth_sso/home.html')


def keycloak_callback(request):
    # Получите токен и информацию о пользователе из запроса
    try:
        code = request.GET['code']
    except Exception:
        return redirect('/login')  # Замените на свой шаблон ошибки

    backend = KeycloakConfidentialBackend()
    data_token = backend.exchange_code_for_token(code)
    if not data_token:
        return redirect('/login')

    # Аутентифицируйте пользователя в Django
    user = backend.authenticate(request, token=data_token)

    if user is not None:
        login(request, user)
        # Пользователь успешно аутентифицирован, теперь вы можете перенаправить его на другую страницу
        return redirect('/')  # Замените на путь, куда вы хотите перенаправить пользователя
    else:
        # Обработка случая, если аутентификация не удалась
        return render(request, 'auth_failed.html')  # Замените на свой шаблон ошибки

Сначала мы пытаемся извлечь code из параметров запроса. Если извлечь code не удается, значит возникла ошибка про запросе. Поэтому создаем редирект на страницу входа.

Взаимодействие с Keycloak реализовано в отдельном классе KeycloakConfidentialBackend. В методе exchange_code_for_token мы обращаемся к API Keycloak, передавая полученный code и client_secret. Повторюсь, client_secret является конфиденциальным, и в случае его раскрытия злоумышленниками, это может создать серьезную угрозу для системы безопасности.

from django.conf import settings


class KeycloakConfidentialBackend:

	@staticmethod
    def exchange_code_for_token(code: str) -> Optional[dict]:
        """Возвращает токен пользователя."""
        token_endpoint = f"{settings.KEYCLOAK_URL_BASE}realms/{settings.REALM_NAME}/protocol/openid-connect/token"
        payload = {
            'code': code,
            'grant_type': 'authorization_code',
            'client_id': settings.CLIENT_ID,
            'client_secret': settings.CLIENT_SECRET,
            'redirect_uri': '/',
        }
        response = requests.post(token_endpoint, data=payload)
        if response.status_code == 200:
            return response.json()
        return None

Если все выполнено корректно, в ответ мы получаем набор данных, среди которых будет access_token. Вот пример данных, получаемых в ответе:

{
    "access_token": ".......",
    "token_type": "Bearer",
    "not-before-policy": 0,
    "session_state": "64287c32-4376-4043-9e57-3c1c76121580",
    "scope": "email profile"
}

На текущий момент, прошли 4 и 5 шаги нашего алгоритма. Перейдем к 5 шагу - декодирование access_token. Для этого нам нужен публичный ключ. В админ панели Keycloak его можно найти в разделе Realm Settings в вкладке Keys. Но будем использовать api Keycloak, чтобы получить публичный ключ в формате Base64 и последствии преобразуем его в объект. Данная логика реализована в методе public_key.

# auth_sso/backends.py
from base64 import b64decode

import jwt
import requests
from cryptography.hazmat.primitives import serialization
from django.conf import settings

class KeycloakConfidentialBackend: 

	@property
    def public_key(self):
        """Возвращает публичный ключ из Keycloak."""
        r = requests.get(f"{settings.KEYCLOAK_URL_BASE}realms/{settings.REALM_NAME}/")
        r.raise_for_status()
        key_der_base64 = r.json()["public_key"]
        key_der = b64decode(key_der_base64.encode())
        return serialization.load_der_public_key(key_der)

	def decode_token(self, data: dict) -> dict:
        """Возвращает декодированные данные из токена."""
        access_token = data['access_token']
        decoded_token = jwt.decode(access_token, key=self.public_key, algorithms=['RS256'],
                                   audience=settings.KEYCLOAK_AUDIENCE)
        return decoded_token

В методе decode_token используя библиотеку jwt декодируем токен, с помощью полученного ранее публичного ключа. НО есть 1 важный момент, связанный с параметром audience.
Сделаю небольшое лирическое отступление, когда изучаешь практически любую новую технологию, все начинается с чтения документации или теоретических статей. На бумаге, все просто и логично. Но технологии не стоят на месте, а статьи устаревают. Авторы часто опускают, некоторые важные моменты. Поэтому когда ты начинаешь работать с технологией на практике, то натыкаешься на целый сад подводных камней. Вот ровно такой, же сценарий получился и когда я писал эту статью.
Не указывая audience в аргументах decode- то получаете ошибку что-то вроде “jwt.exceptions.InvalidAudienceError: Invalid audience”. Пробовал искать ответы на stackoverflow и других сайтах, но однозначного ответа не нашел. Подумал, нужно глянуть traceback ошибки и попробовать разобраться в исходниках jwt по какой причине возникает ошибка.

Traceback (most recent call last):
  File "/django_sso/auth_sso/backends.py", line 47, in authenticate
    user_info = self.decode_token(token)
  File "/Users/shinobi/PycharmProjects/django_sso/auth_sso/backends.py", line 41, in decode_token
    decoded_token = jwt.decode(access_token, key=self.public_key, algorithms=['RS256'])
  File "/django_sso/venv/lib/python3.9/site-packages/jwt/api_jwt.py", line 210, in decode
    decoded = self.decode_complete(
  File "/django_sso/venv/lib/python3.9/site-packages/jwt/api_jwt.py", line 162, in decode_complete
    self._validate_claims(
  File "/django_sso/venv/lib/python3.9/site-packages/jwt/api_jwt.py", line 254, in _validate_claims
    self._validate_aud(
  File "/django_sso/venv/lib/python3.9/site-packages/jwt/api_jwt.py", line 320, in _validate_aud
    raise InvalidAudienceError("Invalid audience")
jwt.exceptions.InvalidAudienceError: Invalid audience

В исходниках нашел функцию _validate_aud, которая осуществляет валидацию поля aud в декодированных данных извлеченых из токена.

# venv/lib/python3.9/site-packages/jwt/api_jwt.py

....

def _validate_aud(
        self,
        payload: dict[str, Any],
        audience: str | Iterable[str] | None,
        *,
        strict: bool = False,
    ) -> None:
        if audience is None:
            if "aud" not in payload or not payload["aud"]:
                return
            # Application did not specify an audience, but
            # the token has the 'aud' claim
            raise InvalidAudienceError("Invalid audience")

        if "aud" not in payload or not payload["aud"]:
            # Application specified an audience, but it could not be
            # verified since the token does not contain a claim.
            raise MissingRequiredClaimError("aud")
....

Видим проверку если audience в аргументах не передан, но он присутствует извлеченных данных. Вызывается исключение InvalidAudienceError. Ниже приведу пример извлеченных данных из токена.

{
    "exp": 1702380992,
    "iat": 1702380692,
    "auth_time": 1702380692,
    "jti": "5d42f7e2-dd84-44f4-89c8-5c68e7dd8871",
    "iss": "http://localhost:8080/auth/realms/Django%20Integration",
    "aud": "account", # значение вот этого поля валидируется _validate_aud
    "sub": "9ec22e34-df10-4a99-b4a0-834c0450e13f",
    "typ": "Bearer",
    "azp": "django-sso",
    "session_state": "c8fa65d1-efe3-4c74-9a12-6b0116d0de3f",
    "acr": "1",
    "allowed-origins": [
        "http://localhost:8010"
    ],
    "realm_access": {
        "roles": [
            "default-roles-django integration",
            "offline_access",
            "uma_authorization"
        ]
    },
    "resource_access": {
        "account": {
            "roles": [
                "manage-account",
                "manage-account-links",
                "view-profile"
            ]
        }
    },
    "scope": "email profile",
    "sid": "c8fa65d1-efe3-4c74-9a12-6b0116d0de3f",
    "email_verified": False,
    "name": "Kazuki Tensei",
    "preferred_username": "kazuki",
    "given_name": "Kazuki",
    "family_name": "Tensei",
    "email": "kazuki@isekai.manga"
}

Теперь понятно, зачем передаем audience - но где брать значение, этого поля? Насколько я понял в процессе поиска информации об этом, по умолчанию значение равно account. В настройках клиента Clients, есть вкладка Mappers и там вы можете, изменить значение этого поля (рис. 14). Вот о таких, не очевидных моментах на мой взгляд не узнаешь, пока не пробуешь применять технологию на практике.

Рис. 14. Изменения значения поля aud в данных токена
Рис. 14. Изменения значения поля aud в данных токена

Возвратимся, к классу KeycloakConfidentialBackend и алгоритму действий. После извлечения данных, нам нужно проверить, существование записи о пользователе в базе данных приложения. Делать это будем в методе authenticate который будет, возвращать объект класса User.

# auth_sso/backends.py

class KeycloakConfidentialBackend: 

	...

	def authenticate(self, request, token: dict, **kwargs):
        # декодируем токен
        try:
            user_info = self.decode_token(token)
        except Exception:
            return None

        # Запрашиваем пользователя из базу данных Django
        user = self.get_user(user_info=user_info)

        # Если пользователь найден и токен действителен, вернем его
        return user

    def get_user(self, user_info) -> Optional[User]:
        user = User.objects.filter(username=user_info['preferred_username']).first()
        if settings.KEYCLOAK_IS_CREATE and not user:
            # Создание пользователя
            user = User.objects.create_user(
                username=user_info['preferred_username'],
                email=user_info['email'],
                last_name=user_info['given_name'],
                first_name=user_info['family_name']
            )

            # Установка пустого пароля
            user.set_unusable_password()

            # Сохранение пользователя
            user.save()
        return user

Есть один нюанс, при связке KeyCloak + Приложение, нет гарантии что пользователь есть базе данных приложения. Во избежание ошибок, добавлен флаг KEYCLOAK_IS_CREATE. Меняя его значение, можно управлять логикой добавления записи о пользователе, при ее отсутствии.

Заключение

В заключение, хочется сделать следующие выводы:

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

  • В процессе подготовки, этой статьи я находил несколько статей про SSO, но или это была сухая теория со схемами, кто с кем взаимодействует, или примеры интеграций на фреймворках Java. Под одной статьей, даже нашел комментарий “как уже достали со своим SSO, напишите лучше SLO (Single Log-Out)”. Но я не нашел статьи, где разбиралась бы интеграция c Django. И возникло желание для себя струтурировать знания по этой области.

  • В статье подробно рассмотрены множество теоретических аспектов, включая аутентификацию, авторизацию, протоколы и прочее, что значительно увеличивает ее объем. Если вас заинтересовала эта тема, пожалуйста, дайте знать в комментариях, стоит ли мне написать вторую часть, посвященную Access Type = Bearer-only.

Исходный код проекта можно найти здесь.

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


  1. rSedoy
    18.01.2024 05:56

    А еще в этой схеме мне не нравятся http запросы внутри views к другому серверу. При равномерных логинах проблему можно и не заметить, а вот допустим акция, в систему разом приходят условные 1к пользователей, синхронному фреймворку от этого станет плохо.


    1. Nireko
      18.01.2024 05:56

      В 5.0 много чего добавили


      1. rSedoy
        18.01.2024 05:56

        тоже хорошо, всё больше мест с async, хотя тут вызовы и не в authenticate, а я больше про этот неприятный момент, когда код views синхронный.


  1. OleSv
    18.01.2024 05:56
    +1

    Поправьте пожалуйста в тексте статьи Reaml на Realm.


    1. MyShinobi Автор
      18.01.2024 05:56

      Спасибо)