Храним секреты в Linux: JWT аутентификация в CLI приложении на Python

  • Tutorial

JSON Web Token — это открытый стандарт для создания токенов доступа, основанный на формате JSON. Как правило, используется для передачи данных для аутентификации в клиент-серверных приложениях. Wikipedia

Когда речь идёт о хранении sensitive data в браузере, достаточно воспользоваться одним из двух доступных вариантов: cookies или localStorage. Тут каждый выбирает по вкусу. Однако я посвятил эту статью Secret Service – службе, которая работает через D-Bus и предназначена для хранения «секретов» в Linux.

У службы есть API, которым пользуется GNOME Keyring для хранения секретов приложений.

Почему Secret Service

Дело в том, что я получал токен не в браузере. Я писал клиентскую аутентификацию для консольного приложения, похожую на ту, что используется в git.

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

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

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

В итоге ключом доступа к токену будет служить пароль учетной записи пользователя Linux.

Архитектура Secret Service вкратце

Основная структура данных Secret Service — это коллекция элементов с атрибутами и секретом.

Коллекция

Это набор всевозможных аутентификационных данных. В системе используется коллекция по-умолчанию под псевдоним «default». В нее записываются все пользовательские приложения. Seahorse нам её покажет.

Как видно, в моё хранилище сохранились Google Chrome и VSCode. Сюда же будет сохраняться и моё приложение.

Каждая такая запись называется элементом.

Элемент

Часть коллекции, хранящая атрибуты и секрет.

Атрибуты

Пара вида ключ, значение, которая содержит название приложения и служит для идентификации элемента.

Секрет

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

Алгоритм взаимодействия с Secret Service

Я долго думал над алгоритмом аутентификации, пока не набросал flow chart.

Определяем операторы

  • «Токен в хранилище?» — функция и условие.

  • «Извлечь токен из хранилища» — функция.

  • «Запросить регистрационные данные у пользователя» — функция.

  • «Запросить токен у API» — функция.

  • «Сохранить токен в хранилище» — функция.

  • «Использовать токен» — конец.

Реализация на Python

Я решил попробовать Click Framework для создания CLI приложения.

import click

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

@click.group()
def cli():
    pass

Для логина в приложение я создам одноименную команду.

В консоле это будет выглядеть так:

$ app login
Email:
Password:

Или так когда токен получен:

$ app login
Logged in!

Команда login

@cli.command(help="Login into your account.")
@click.option(
    '--email',
    prompt=True,
    help='Registered email address.')
@click.option(
    '--password',
    prompt=True,
    hide_input=True,
    help='Password provided at registration.'
    )
def login(email, password):
        pass

if __name__ == '__main__':
    cli()

Все декораторы применяются к функции login, которая пока не имплементирована, но принимает значения параметров email и password.

Декоратор @cli.command добавляет команду в группу, а @click.option делает параметры функции опциями команды.

В опции пароля, параметр hide_input скрывает символы при вводе в консоле.

Параметр prompt принимает булевые значения, согласно которым Сlick Framework решает, запрашивать ли значение параметра у пользователя.

С последним у меня проблемы

Я не могу просто присвоить ему True или False потому, что:

  • в случае True Click Framework запрашивает опцию при каждом запуске. Мне это не подходит. Достаточно запросить почту и пароль при первом запуске, получить токен от WEB API и сохранить его в Secret Service, а в дальнейшем запрашивать токен у Secret Service API;

  • В случае False Click Framework вообще не запрашивает опцию. Значит, я не смогу получить токен, если приложение запущено впервые и токен отсутствует в Secret Service.

Решение

Мне нужна функция, которая примет решение и вернет соответствующее значение в переменную prompt_decision. А зависит это решение от наличия или отсутствия токена в Secret Service. Отсюда следует, что функция подключается к Secret Service API и запрашивает токен.

Последствия

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

Напротив, если основной модуль будет содержать код, исключительно относящийся к Click Framework, он будет выглядеть понятно и лаконично.

В итоге модуль app содержит логику паттерна Click Framework. В него я буду импортировать модуль auth, в котором будет класс Auth с логикой аутентификации.

.
├── auth.py
└── app.py

Я буду хранить значение решения о запросе почты и пароля у пользователя в атрибуте prompt_decision объекта auth класса Auth модуля auth.

@cli.command(help="Login into your account.")
@click.option(
    '--email',
    prompt=auth.prompt_decision,
    help='Registered email address.')
@click.option(
    '--password',
    prompt=auth.prompt_decision,
    hide_input=True,
    help='Password provided at registration.'
    )
def login(email, password):
        pass

if __name__ == '__main__':
    cli()

Пишем модуль аутентификации

Для Python доступен пакет SecretStorage, который использует Secret Service API.

Он оперирует основными понятиями службы и включает в себя два модуля, которые реализуют классы и функции доступа к основным объектам Secret Service.

Здесь будет реализован класс доступа к Secret Service API и WEB API, определен атрибут prompt_decision и соответствующий метод.

Импортируем необходимые пакеты

  • requests — для HTTP запросов к WEB API.

  • secretstorage — для запросов к Secret Service API.

  • json — для десериализации байтов в словарь.

import requests
import secretstorage
import json

Получаем секрет из SecretStorage

class Auth:
    def __init__(self, email=None, password=None):
        # атрибуты, по которым осуществляется поиск элемента
        # Secret Service
        self._attributes = {'application': 'MyApp'}
        # подключение к Dbus
        self._connection = secretstorage.dbus_init()
        # запрос коллекции по-умолчанию
        self._collection = secretstorage.collection.get_default_collection(
            self._connection
            )
        # запрос всех элементов коллекции с указанными атрибутами
        self._items = self._collection.search_items(self._attributes)
        # получение конечного атрибута
        self._stored_secret = self.get_stored_secret()

На данном этапе я запросил нужный мне элемент коллекции.

Критерием поиска для Secret Service служит атрибут self._attributes.

О символе «_» в названиях атрибутов

Нижнее подчеркивание в названиях атрибутов означает, что к ним не предполагается обращаться извне. Впрочем, это не делает их недоступными для других объектов. Интерпретатор не изолирует их в отдельном пространстве имён, доступном только из области видимости объекта. Так обозначаются атрибуты, которые нигде, кроме самого объекта не используются. И это не более, чем солгашение.

Стоит отметить, что заданному критерию поиска в коллекции может соответствовать не один элемент. По этой причине автор(ы) SecretStorage решил(и) возвращать генератор в ответ на поисковый запрос. Не вдаваясь в подробности, это означает, что необходимо итерировать по self._items в поисках нужного элемента.

Я делаю это в методе get_stored_secret, который возвращает готовый секрет.

class Auth:
    def get_stored_secret(self):
        for item in self._items:
            if item:
                return json.loads(item.get_secret())

В цикле итератор становится экземпляром класса Item пакета secretstorage, поэтому на нём можно вызвать метод get_secret, который возвращает секрет.

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

Далее следует десериализация секрета и возврат словаря.

True или False — вот, в чём вопрос

В отличие от источника, моя аллегория в заголовке носит менее риторический характер, и я смело могу ответить: «Для начала — False».

class Auth:        
    def __init__(self, email=None, password=None):
        # все, что было написано до этого
        self.prompt_decision = False

Только постоянное изменяется; изменчивое подвергается не изменению, а только смене. Иммануил Кант

И Буль своей логикой нисколько не противоречит Канту. Возможно, Гамлету стоило взять это на вооружение.

class Auth:        
    def __init__(self, email=None, password=None):
        # все, что было написано до этого
        # если секрет получен
        if self._stored_secret:
            # он будет доступен в атрибуте token
            self.token = self._stored_secret['token']
        # если пароль и почта запрошены у пользователя
        elif email and password:
            # получить токен у WEB API
            self.token = self.get_token(email, password)
            # сохранить токен как актуальный
            self._valid_secret = {'token': self.token}
            # сохранить токен в Secret Service
            self.set_stored_secret()
        else:
            # если токена нет в Secret Storage, нужно запросить почту и пароль
            # пользователя
            self.prompt_decision = True

Таким образом объекты моего класса инициализируются по-разному в зависимости от наличия параметров почты и пароля.

Инициализация без параметров

  1. Запросить токен у Secret Storage API.

  2. Если токен найден, не запрашивать почту и пароль у пользователя.

  3. Использовать токен из Secret Storage.

Инициализация с параметрами

  1. Запросить токен у Secret Storage API.

  2. Если токен найден, не запрашивать почту и пароль у пользователя и использовать токен из Secret Storage.

  3. Если токен не найден, принять решение о запросе почты и пароля у пользователя.

  4. Если пароль и почта предоставлены, запросить токен у WEB API.

  5. Если токен получен, сохранить его в Secret Storage.

Осталось реализовать методы сохранения токена в Secret Storage и запроса к WEB API.

Получаем актуальный токен у WEB API

class Auth:        
    def get_token(self, email: str, password: str) -> str:
        try:
            response = requests.post(
                API_URL,
                data= {
                    'email': email,
                    'passwd': password
                    })
            data = response.json()
        except requests.exceptions.ConnectionError:
            raise requests.exceptions.ConnectionError()
        if response.status_code != 200:
            raise requests.exceptions.HTTPError(data['msg'])
        return data['data']['token']

В константе API_URL располагается адрес моего API. По понятным причинам, я не могу его опубликовать. Однако, он возвращает токен при POST запросе с параметрами «email» и «passwd».

Метод возвращает два исключения при ошибки подключения к API и во всех случаях, когда ответ API не содержит токена.

Во всех таких случаях API возвращает объект «msg» с соответствующим сообщением из тела ответа. После сериализации в блоке try я могу просто выводить это сообщение в консоль.

Сам токен хранится в объекте «data».

Сохраняем токен в Secret Storage

class Auth:        
    def set_stored_secret(self):
        self._collection.create_item(
            'MyApp',
            self._attributes,
            bytes((json.dumps(self._valid_secret)), 'utf-8')
            )

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

Используем модуль в Сlick Framework

Сперва импортируем модуль auth.

from auth import Auth

Затем инициализируем объект без параметров для проверки Secret Storage.

auth = Auth()

Передаем решение в декораторы опций.

@cli.command(help="Login into your account.")
@click.option(
    '--email',
    prompt=auth.prompt_decision,
    help='Registered email address.')
@click.option(
    '--password',
    prompt=auth.prompt_decision,
    hide_input=True,
    help='Password provided at registration.'
    )

Дописываем команду login.

def login(email, password):
    global auth
    try:
      	# если было принято решение о запросе почты и пароля
        if auth.prompt_decision:
          	# получить новый токен и сохранить его в Secret Storage
            auth = Auth(email, password)
    except Exception:
        return click.echo('No API connection')
		# далее следует любая логика работы, связанная с токеном.
    click.echo(auth.token)

Радуемся автоматически сгенерированной справке

Click Framework генерирует справку автоматически. Для этого ему нужны строки, которые я указывал в параметреhelpего декораторов.

$ python app.py 
Usage: app.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  login  Login into your account.

Справка команды login

$ python app.py login --help
Usage: app.py login [OPTIONS]

  Login into your account

Options:
  --email TEXT     Registered email address
  --password TEXT  Password provided at registration
  --help           Show this message and exit.

Проверка

После запуска приложения командой python app.py login оно запросит почту и пароль. Если эти данные верны, то в Secret Service появится соответствующий элемент.

В нём действительно хранится токен.

При повторном запуске приложение не будет спрашивать реквизиты, а загрузит токен уже из Secret Service.

Ссылки

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое