Разработка системы аутентификации на Java+Tarantool / Блог компании Mail.ru Group / Хабр

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

Меня зовут Александр, я программист в отделе архитектуры и пресейла в Mail.ru Group. Я расскажу, как построить систему аутентификации на основе Tarantool и Java. Нам в пресейле очень часто приходится делать именно такие системы. Способов аутентификации очень много: по паролю, биометрическим данным, SMS и т.п. Для наглядности я покажу, как сделать аутентификацию по паролю.

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



Система аутентификации проверяет подлинность данных, введенных пользователем. С такими системами мы сталкиваемся везде, начиная от операционных систем и заканчивая различными сервисами. Видов аутентификации очень много: по паре логин-пароль, с помощью электронной подписи, по биометрическим данным и т.д. Я выбрал систему логин-пароль в качестве примера, потому что она встречается чаще всего и достаточно проста. А ещё она позволяет показать основные возможности Cartridge и Cartridge Java, нам достаточно будет написать относительно немного кода. Но обо всём по порядку.

Основы систем аутентификации

В любой системе аутентификации обычно можно выделить несколько элементов:

  • субъект, который будет проходить процедуру;
  • характеристика субъекта — отличительная черта;
  • хозяин системы аутентификации, несущий ответственность и контролирующий её работу;
  • механизм аутентификации, то есть принцип работы системы;
  • механизм управления доступом, предоставляющий определенные права доступа субъекту.

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

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

Что такое Tarantool Cartridge и Cartridge Java?

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

Для работы с кластером из какого-либо приложения необходимо использовать так называемые коннекторы — драйверы для взаимодействия с базой данных и кластером по специальному бинарному протоколу iproto. На текущий момент у нас есть коннекторы для таких языков программирования, как Go, Java, Python и др., часть из которых может работать только с одним экземпляром Tarantool, другие же могут работать с целыми кластерами. Одним из таких коннекторов является Cartridge Java, который позволяет нам взаимодействовать с кластером из приложения на Java. И здесь, собственно, возникает вопрос: а почему именно этот язык?

Почему именно Java?

Я работаю в отделе архитектуры и пресейла, а это означает, что мы делаем пилотные проекты для заказчиков из разных областей бизнеса. Под пилотным проектом подразумевается прототип системы, который впоследствии будет доработан и передан заказчику. Поэтому в числе наших заказчиков чаще всего люди, которые используют для разработки языки, позволяющие создавать enterprise-решения. Одним из таких языков и является Java. Поэтому мы выбрали коннектор Cartridge Java.

Почему аутентификация?

Дальше возникает вопрос выбора сервиса, на примере которого мы хотим продемонстрировать технологии. Почему же мы взяли именно аутентификацию, а не какой-то другой сервис? Ответ достаточно прост: это наиболее частая задача, которую пытаются решить не только с помощью Tarantool, но и с помощью других баз данных.

Аутентификация встречается нам практически во всех более-менее приличных приложениях. Чаще всего для хранения профилей пользователей используются такие базы данных, как MySQL или PostgreSQL. Однако применение Tarantool здесь наиболее уместно, потому что он может справиться с десятками тысяч запросов в секунду за счёт того, что все данные хранятся в ОЗУ, in-memory. А при падении экземпляра он может достаточно быстро восстановиться благодаря использованию snapshot’ов и write-ahead логов.

Теперь разберём, какая же структура будет у нашего сервиса. Он будет состоять из двух частей:

  • приложение на Tarantool Cartridge, выполняющее роль базы данных;
  • приложение на Java, предоставляющее API для выполнения основных операций.

Рассмотрим первую часть нашего сервиса:

Приложение на Tarantool Cartridge

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

Роутер — это экземпляр с ролью router, который отвечает за маршрутизацию запросов к хранилищам. Мы немного расширим его функциональность. Как это сделать, расскажу ниже.

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

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

Создание и настройка приложения

Создадим приложение, выполнив команду:

$ cartridge create –-name authentication

Будет создана директория authentication, содержащая всё необходимое для создания кластера. Зададим список экземпляров в файле

instances.yml

:

---
authentication.router:
  advertise_uri: localhost:3301
  http_port: 8081
 
authentication.s1-master:
  advertise_uri: localhost:3302
  http_port: 8082
 
authentication.s1-replica:
  advertise_uri: localhost:3303
  http_port: 8083
 
authentication.s2-master:
  advertise_uri: localhost:3304
  http_port: 8084
 
authentication.s2-replica:
  advertise_uri: localhost:3305
  http_port: 8085
 
authentication-stateboard:
  listen: localhost:4401
  password: passwd

Теперь нам необходимо настроить роли.

Настройка ролей

Чтобы наше приложение могло работать с коннектором Cartridge Java, нам необходимо создать и настроить новые роли. Сделать это можно, продублировав файл

custom.lua

и переименовав полученные файлы в

storage.lua

и

router.lua

в папке

app/roles

, а затем поменяв в них настройки. Сперва необходимо изменить имя роли в

return

в поле

role_name

. В

router.lua

роль будет называться

router

, а в

storage.lua

storage

. Затем необходимо указать соответствующие имена ролей в

init.lua

в секции

roles

в

cartridge.cfg

.

Для работы с Cartridge Java нам нужно установить модуль ddl, добавив в файл с расширением *.rockspec в секцию dependencies строку 'ddl == 1.3.0-1'. После этого в router.lua добавим функцию get_schema:

function get_schema()
    for _, instance_uri in pairs(cartridge_rpc.get_candidates('app.roles.storage', { leader_only = true })) do
        local conn = cartridge_pool.connect(instance_uri)
        return conn:call('ddl.get_schema', {})
    end
end

И в функцию

init

добавим строку:

rawset(_G, 'ddl', { get_schema = get_schema })

Помимо этого, в

storage.lua

в функцию

init

добавим условие:

 if opts.is_master then
        rawset(_G, 'ddl', { get_schema = require('ddl').get_schema })
 end

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

rawset

. Перейдём к определению топологии кластера.

Создание топологии и запуск кластера

Зададим топологию кластера в файле

replicasets.yml

:

router:
  instances:
  - router
  roles:
  - failover-coordinator
  - router
  all_rw: false
s-1:
  instances:
  - s1-master
  - s1-replica
  roles:
  - storage
  weight: 1
  all_rw: false
  vshard_group: default
s-2:
  instances:
  - s2-master
  - s2-replica
  roles:
  - storage
  weight: 1
  all_rw: false
  vshard_group: default

После определения конфигурации экземпляров и топологии, выполним команды для сборки и запуска нашего кластера:

$ cartridge build
$ cartridge start -d

Будут созданы и запущены экземпляры, которые мы задали в

instances.yml

. Теперь мы можем перейти в браузере по адресу

http://localhost:8081

, где увидим графический интерфейс для управления нашим кластером, в котором будут указаны созданные экземпляры. Однако на текущий момент они не сконфигурированы и не объединены в наборы реплик так, как мы указали в

replicasets.yml

. Чтобы вручную не настраивать экземпляры, выполним команду:

$ cartridge replicasets setup -bootstrap-vshard

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

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

Создание модели

На самом деле, мы пока им пользоваться не можем, потому что у нас нет модели, которая описывает пользователя. Давайте подумаем, как же лучше его описать? Какую информацию о пользователе мы хотим хранить? Так как наш пример достаточно простой, то в качестве основной информации о пользователе возьмём следующие поля:

  • uuid — уникальный идентификатор пользователя;
  • login — логин пользователя;
  • password — поле, содержащее хеш-сумму от пароля пользователя.

Это основные поля, которые будет содержать модель. Их достаточно, когда пользователей мало и нагрузка небольшая. Но что будет, когда количество пользователей станет огромным? Мы, вероятно, захотим сделать шардирование, чтобы была возможность разнести пользователей на разные хранилища, а те, в свою очередь, на разные машины или в разные ЦОДы. Тогда по какому полю шардировать пользователей? Есть два варианта: по UUID и по логину. Мы будем шардировать пользователей по логину.

Чаще всего ключ шардирования выбирается таким образом, чтобы записи из разных спейсов, имеющие одинаковый ключ шардирования, лежали на одном и том же хранилище. Но так как в нашей задаче всего один спейс, мы выбираем то поле, которое больше нравится. После этого надо подумать, какой алгоритм мы будем использовать для шардирования? К счастью, необходимость выбора отпадает, потому что в Tarantool Cartridge используется библиотека vshard, в которой применяется алгоритм виртуального шардирования, о нём можно почитать здесь. Чтобы им воспользоваться, нам необходимо добавить в модель еще одно поле — bucket_id. Значение данного поля будет высчитываться на основе значения поля login. Теперь мы можем полностью описать наш спейс:

local user_info = box.schema.create_space('user_info', {
            format = {
                { name="bucket_id", type="unsigned" },
                { name="uuid", type="string" },
                { name="login", type="string" },
                { name="password", type="string" },
            },
            if_not_exists = true,
        })

Чтобы с начать работать со спейсом необходимо создать хотя бы один индекс. Создадим первичный индекс полю

login

, который назовём

primary

:

user_info:create_index('primary', {
            parts = { 'login' },
            if_not_exists = true,
        })

Так как мы используем vshard, нам также необходимо создать вторичный индекс по полю

bucket_id

:

user_info:create_index('bucket_id', {
            parts = { 'bucket_id' },
            if_not_exists = true,
            unique = false
        })

Также добавим ключ шардирования по полю

login

:

utils.register_sharding_key('user_info', {'login'})

Работа с миграциями

Для работы со спейсами будем использовать модуль migrations. Для этого необходимо добавить в файл с расширением

*.rockspec

в секцию

dependencies

строку:

'migrations == 0.4.0-1'

Для работы с этим модулем надо создать папку

migrations

в корне приложения и положить в неё файл

0001_initial.lua

с таким содержимым:

local utils = require('migrator.utils')
 
return {
    up = function()
        local user_info = box.schema.create_space('user_info', {
            format = {
                { name="bucket_id", type="unsigned" },
                { name="uuid", type="string" },
                { name="login", type="string" },
                { name="password", type="string" },
            },
            if_not_exists = true,
        })
 
        user_info:create_index('primary', {
            parts = { 'login' },
            if_not_exists = true,
        })
 
        user_info:create_index('bucket_id', {
            parts = { 'bucket_id' },
            if_not_exists = true,
            unique = false
        })
 
        utils.register_sharding_key('user_info', {'login'})
        
        return true
    end
}

Чтобы наш спейс создался, надо отправить POST-запрос по адресу

http://localhost:8081/migrations/up

, например, так:

$ curl –X POST http://localhost:8081/migrations/up

Тем самым мы применяем миграцию. При создании новых миграций надо добавить в

migrations

новые файлы, имена которых начинаются с 0002-…, и выполнить приведённую выше команду.

Создание хранимых процедур

После продумывания модели и создания спейса нам необходимо создать функции, с помощью которых наше приложение на Java будет взаимодействовать с кластером. Такие функции называются хранимыми процедурами, они вызываются на роутерах и манипулируют данными посредством вызова определённых методов спейса.

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

С основными хранимыми процедурами мы определились, теперь пришло время их реализовать. Вся реализация будет храниться в файле app/roles/router.lua. Начнём с реализации процедуры создания пользователя, но для начала создадим некоторые вспомогательные константы:

local USER_BUCKET_ID_FIELD = 1
local USER_UUID_FIELD = 2
local USER_LOGIN_FIELD = 3
local USER_PASSWORD_FIELD = 4

Как видно из названий, константы определяют номера соответствующих полей в спейсе. Они позволят нам использовать осмысленные имена при индексации полей в кортеже в наших хранимых процедурах. Теперь приступим к созданию первой процедуры. Назовём её

create_user

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

function create_user(uuid, login, password_hash)
    local bucket_id = vshard.router.bucket_id_mpcrc32(login)
 
    local _, err = vshard.router.callrw(bucket_id, 'box.space.user_info:insert', {
        {bucket_id, uuid, login, password_hash }
    })
 
    if err ~= nil then
        log.error(err)
        return nil
    end
 
    return login
end

  1. Первым делом мы с помощью vshard.router.bucket_id_mpcrc32 вычисляем bucket_id, по которому будут шардироваться наши записи.
  2. Затем вызываем функцию insert, у спейса на бакете с вычисленным bucket_id, которому передаём кортеж, состоящий из полей bucket_id, uuid, login и password_hash. Этот вызов выполняется с помощью вызова vshard.router.callrw библиотеки vshard, позволяющего выполнять операции записи в спейс и возвращающего результат выполнения вызываемой функции (и ошибку в случае неудачи).
  3. Затем мы проверяем, удачно выполнилась наша функция или нет, и в случае ошибки возвращаем nil, а при успешной вставке возвращаем логин пользователя.

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

get_user_by_login

. Для неё мы воспользуемся следующим алгоритмом:

  1. Вычисляем по логину bucket_id.
  2. Вызываем у спейса функцию get на вычисленном бакете с помощью vshard.router.callbro.
  3. Если пользователь с указанным логином существует, то возвращаем кортеж с информацией о нём, иначе возвращаем nil.

Реализация:

function get_user_by_login(login)
 
    local bucket_id = vshard.router.bucket_id_mpcrc32(login)
 
    local user = vshard.router.callbro(bucket_id, 'box.space.user_info:get', {login})
    return user
end

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

Рассмотрим случай, когда пользователь решил обновить информацию о себе, в нашем случае это будет пароль. Напишем функцию, которую назовём update_user_by_login. На вход она принимает логин и хеш нового пароля. Какой алгоритм нам необходимо использовать? Сперва попробуем получить информацию о пользователе с помощью уже реализованной нами get_user_by_login. Если пользователь не существует, то вернём nil. Иначе вычислим bucket_id по его логину, и вызовем на бакете функцию update нашего спейса, в которую передадим логин пользователя и кортеж, содержащий информацию о поле, которое нам надо обновить — присвоить новый хеш пароля. Если при обновлении произошла ошибка, то логируем её и вернём nil, иначе вернём кортеж с информацией о пользователе. На Lua эта процедура будет выглядеть так:

function update_user_by_login(login, new_password_hash)
    local user = get_user_by_login(login)
 
    if user ~= nil then
        local bucket_id = vshard.router.bucket_id_mpcrc32(user[USER_LOGIN_FIELD])
 
        local user, err = vshard.router.callrw(bucket_id, 'box.space.user_info:update', { user[USER_LOGIN_FIELD], {
            {'=', USER_PASSWORD_FIELD, new_password_hash }}
        })
 
        if err ~= nil then
            log.error(err)
            return nil
        end
 
        return user
    end
 
    return nil
end

И напоследок реализуем последнюю процедуру: удаление пользователя. Назовём её

delete_user_by_login

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

delete

и возвращена информация об удалённом пользователе, иначе вернём

nil

. Реализация этой хранимой процедуры:

function delete_user_by_login(login)
 
    local user = get_user_by_login(login)
 
    if user ~= nil then
 
        local bucket_id = vshard.router.bucket_id_mpcrc32(user[USER_LOGIN_FIELD])
 
        local _, _ = vshard.router.callrw(bucket_id, 'box.space.user_info:delete', {
            {user[USER_LOGIN_FIELD]}
        })
 
        return user
    end
 
    return nil
 
end

Итого

  • Создали приложение.
  • Настроили роли.
  • Сконфигурировали топологию.
  • Запустили кластер.
  • Описали модель и создали миграцию.
  • Реализовали хранимые процедуры.

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

Приложение на Java

Приложение на Java будет выполнять роль API и предоставлять бизнес-логику для аутентификации пользователей. Так как это enterprise-приложение, создавать его будем во фреймворке Spring. Для сборки используем фреймворк Apache Maven.

Установка коннектора

Для установки коннектора добавим в

pom.xml

в секцию

dependencies

зависимость:

<dependency>
     <groupId>io.tarantool</groupId>
     <artifactId>cartridge-driver</artifactId>
     <version>0.4.2</version>
</dependency>

После это необходимо обновить зависимости. Последнюю версию коннектора можно посмотреть

здесь

. Установив коннектор, необходимо импортировать из

io.tarantool.driver;

классы, которые будем использовать.

Подключение к кластеру

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

TarantoolConfig

. Укажем, что он является конфигурационным и берёт свои параметры из файла

application-tarantool.properties

:

@Configuration
@PropertySource(value="classpath:application-tarantool.properties", encoding = "UTF-8")

Файл

application-tarantool.properties

содержит в себе поля:

tarantool.nodes=localhost:3301 # список нод
tarantool.username=admin # имя пользователя
tarantool.password=authentication-cluster-cookie # пароль

Они необходимы для подключения к кластеру. Именно эти параметры принимает на вход конструктор нашего класса:

public TarantoolClient tarantoolClient(
            @Value("${tarantool.nodes}") String nodes,
            @Value("${tarantool.username}") String username,
            @Value("${tarantool.password}") String password)

Поля

username

и

password

мы будем использовать для создания Credentials — параметров для аутентификации:

SimpleTarantoolCredentials credentials = new SimpleTarantoolCredentials(username, password);

Зададим клиентскую конфигурацию для подключения к кластеру, а именно укажем параметры для аутентификации и таймаут запроса:

TarantoolClientConfig config = new TarantoolClientConfig.Builder()
                .withCredentials(credentials)
                .withRequestTimeout(1000*60)
                .build();

Далее необходимо передать список нод в так называемый

AddressProvider

, содержащий логику преобразования строки в список адресов и возвращающий этот список:

TarantoolClusterAddressProvider provider = new TarantoolClusterAddressProvider() {
            @Override
            public Collection<TarantoolServerAddress> getAddresses() {
                ArrayList<TarantoolServerAddress> addresses = new ArrayList<>();
 
                for (String node: nodes.split(",")) {
                    String[] address = node.split(":");
                    addresses.add(new TarantoolServerAddress(address[0], Integer.parseInt(address[1])));
                }
 
                return addresses;
            }
        };

И наконец, мы создаём клиент, который будет подключаться к кластеру. Оборачиваем его в специальный proxy-клиент и возвращаем результат, обёрнутый в retrying-клиент, который при неудачной попытке подключения пытается подключить ещё раз, пока не исчерпает указанное количество попыток:

ClusterTarantoolTupleClient clusterClient = new ClusterTarantoolTupleClient(config, provider);
        ProxyTarantoolTupleClient proxyClient = new ProxyTarantoolTupleClient(clusterClient);
 
        return new RetryingTarantoolTupleClient(
                proxyClient,
                TarantoolRequestRetryPolicies.byNumberOfAttempts(
                        10, e -> e.getMessage().contains("Unsuccessful attempt")
                ).build());

Полный код класса:

@Configuration
@PropertySource(value="classpath:application-tarantool.properties", encoding = "UTF-8")
public class TarantoolConfig {
 
    @Bean
    public TarantoolClient tarantoolClient(
            @Value("${tarantool.nodes}") String nodes,
            @Value("${tarantool.username}") String username,
            @Value("${tarantool.password}") String password) {
 
        SimpleTarantoolCredentials credentials = new SimpleTarantoolCredentials(username, password);
 
        TarantoolClientConfig config = new TarantoolClientConfig.Builder()
                .withCredentials(credentials)
                .withRequestTimeout(1000*60)
                .build();
 
        TarantoolClusterAddressProvider provider = new TarantoolClusterAddressProvider() {
            @Override
            public Collection<TarantoolServerAddress> getAddresses() {
                ArrayList<TarantoolServerAddress> addresses = new ArrayList<>();
 
                for (String node: nodes.split(",")) {
                    String[] address = node.split(":");
                    addresses.add(new TarantoolServerAddress(address[0], Integer.parseInt(address[1])));
                }
 
                return addresses;
            }
        };
 
        ClusterTarantoolTupleClient clusterClient = new ClusterTarantoolTupleClient(config, provider);
        ProxyTarantoolTupleClient proxyClient = new ProxyTarantoolTupleClient(clusterClient);
 
        return new RetryingTarantoolTupleClient(
                proxyClient,
                TarantoolRequestRetryPolicies.byNumberOfAttempts(
                        10, e -> e.getMessage().contains("Unsuccessful attempt")
                ).build());
    }
}

Когда приложение после запуска впервые попытается отправить запрос в Tarantool, оно подключится к кластеру. Перейдём к созданию API и модели пользователя нашего приложения.

Создание API и модели пользователя

Будем использовать спецификацию OpenAPI версии 3.0.3. Создадим три конечные точки, каждая из которых будет принимать соответствующие виды запросов и обрабатывать их:

  • /register

    • POST — создание пользователя.
  • /login

    • POST — аутентификация пользователя.
  • /{login}

    • GET — получение информации о пользователе;
    • PUT — обновление информации о пользователе;
    • DELETE — удаление пользователя.

Также добавим описание методов, которые будут обрабатывать каждый из наших запросов и ответов, возвращаемые приложением:

  • authUserRequest
  • authUserResponse
  • createUserRequest
  • createUserResponse
  • getUserInfoResponse
  • updateUserRequest

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

Теперь необходимо сгенерировать классы, соответствующие описанным методам и ответам. Для этого воспользуемся плагином swagger-codegen. Добавим в pom.xml в секцию build описание плагина:

<plugin>
   <groupId>io.swagger.codegen.v3</groupId>
   <artifactId>swagger-codegen-maven-plugin</artifactId>
   <version>3.0.21</version>
   <executions>
      <execution>
         <id>api</id>
         <goals>
            <goal>generate</goal>
          </goals>
          <configuration>
             <inputSpec>${project.basedir}/src/main/resources/api.yaml</inputSpec>
             <language>java</language>
             <modelPackage>org.tarantool.models.rest</modelPackage>
             <output>${project.basedir}</output>
             <generateApis>false</generateApis>
             <generateSupportingFiles>false</generateSupportingFiles>
             <generateModelDocumentation>false</generateModelDocumentation>
             <generateModelTests>false</generateModelTests>
             <configOptions>
                <dateLibrary>java8</dateLibrary>
                <library>resttemplate</library>
                <useTags>true</useTags>
                <hideGenerationTimestamp>true</hideGenerationTimestamp>
             </configOptions>
         </configuration>
      </execution>
   </executions>
</plugin>

В нём мы указываем путь к файлу

api.yaml

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

Перейдём к созданию модели пользователя. Класс будет называться UserModel. Поместим его в папку models. В той же папке в подпапке rest лежат классы запросов/ответов. Модель будет описывать пользователя и содержать три приватных поля: uuid, login и password. Также в ней будут геттеры и сеттеры для доступа к этим полям. Окончательный вид модели:

public class UserModel {
 
    String uuid;
    String login;
    String password;
 
    public String getUuid() {
        return uuid;
    }
 
    public void setUuid(String uuid) {
        this.uuid = uuid;
    }
 
    public String getLogin() {
        return login;
    }
 
    public void setLogin(String login) {
        this.login = login;
    }
 
    public String getPassword() {
        return password;
    }
 
    public void setPassword(String password) {
        this.password = password;
    }
}

Создание сервисов и контроллеров

Для работы с Tarantool при обработке запросов мы воспользуемся сервисами, которые позволят нам скрыть всю логику за вызовом методов определённого класса. Будем пользоваться четырьмя основными методами:

  • getUserByLogin — получение информации о пользователе по его логину;
  • createUser — создание пользователя;
  • updateUser — обновление информации о пользователе;
  • deleteUser — удаление пользователя по его логину.

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

StorageService

:

public interface StorageService {
 
    UserModel getUserByLogin(String login);
 
    String createUser(CreateUserRequest request);
 
    boolean updateUser(String login, UpdateUserRequest request);
 
    boolean deleteUser(String login);
}

Также создадим класс

TarantoolStorageService

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

TarantoolClient,

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

final

:

private final TarantoolClient tarantoolClient;
 
    public TarantoolStorageService(TarantoolClient tarantoolClient) {
        this.tarantoolClient = tarantoolClient;
    }

Теперь переопределим метод получения пользователя по логину. Сначала создадим переменную

userTuple

типа

List<Objеct>

, которая будет определена как

null

:

List<Object> userTuple = null;

После инициализации пробуем выполнить у

tarantoolClient

метод

call

, результатом выполнения которого будет

Future

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

get

с аргументом

0

. Если при вызове метода

call

возникает исключение, то мы его перехватываем и выводим в консоль.

try {
    userTuple = (List<Object>) tarantoolClient.call("get_user_by_login",login).get().get(0);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

А если метод выполнился успешно, то создаём объект класса

UserModel

, заполняем все поля и возвращаем его; иначе возвращаем

null

.

if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));
 
            return user;
        }
 
        return null;

Полный код метода:

public UserModel getUserByLogin(String login) {
 
        List<Object> userTuple = null;
 
        try {
            userTuple = (List<Object>) tarantoolClient.call("get_user_by_login", login).get().get(0);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
 
        if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));
 
            return user;
        }
 
        return null;
    }

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

@Service
public class TarantoolStorageService implements StorageService{
 
    private final TarantoolClient tarantoolClient;
 
    public TarantoolStorageService(TarantoolClient tarantoolClient) {
        this.tarantoolClient = tarantoolClient;
    }
 
    @Override
    public UserModel getUserByLogin(String login) {
 
        List<Object> userTuple = null;
 
        try {
            userTuple = (List<Object>) tarantoolClient.call("get_user_by_login", login).get().get(0);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
 
        if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));
 
            return user;
        }
 
        return null;
    }
 
    @Override
    public String createUser(CreateUserRequest request) {
 
        String uuid = UUID.randomUUID().toString();
        List<Object> userTuple = null;
 
        try {
            userTuple = (List<Object>) tarantoolClient.call("create_user",
                    uuid,
                    request.getLogin(),
                    DigestUtils.md5DigestAsHex(request.getPassword().getBytes())
            ).get();
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
 
        if(userTuple != null) {
            return (String) userTuple.get(0);
        }
 
        return null;
    }
 
    @Override
    public boolean updateUser(String login, UpdateUserRequest request) {
 
        List<Object> userTuple = null;
 
        try {
            userTuple = (List<Object>) tarantoolClient.call("update_user_by_login",
                    login, DigestUtils.md5DigestAsHex(request.getPassword().getBytes())
            ).get().get(0);
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
 
        return userTuple != null;
    }
 
    @Override
    public boolean deleteUser(String login) {
        List<Object> userTuple = null;
 
        try {
            userTuple = (List<Object>) tarantoolClient.call("delete_user_by_login",
                    login
            ).get().get(0);
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
 
        return userTuple != null;
    }
}

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

UserService

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

StorageService

и вызывает методы, которые в нём определены. Поэтому просто приведу его полный код:

@Service
public class UserService {
    private final StorageService storageService;
 
    public UserService(StorageService storageService) {
        this.storageService = storageService;
    }
 
    public String createUser(CreateUserRequest request) {
        return this.storageService.createUser(request);
    }
 
    public boolean deleteUser(String login) {
        return this.storageService.deleteUser(login);
    }
 
    public UserModel getUserByLogin(String login) {
        return this.storageService.getUserByLogin(login);
    }
 
    public boolean updateUser(String login, UpdateUserRequest request) {
        return this.storageService.updateUser(login, request);
    }
}

Второй же сервис, который аутентифицирует пользователя, мы назовём

AuthenticationService

. Он также будет инициализироваться объектом класса

StorageService

и содержит один метод —

authenticate

, отвечающий за аутентификацию пользователя. Как происходит аутентификация? Метод по логину запрашивает в Tarantool данные пользователя. Затем вычисляет MD5-хеш пароля и сравнивает его с тем, что получил из Tarantool. Если совпадают, то метод возвращает токен, который для простоты является UUID пользователя, а иначе возвращает

null

. Полный код класса:

@Service
public class AuthenticationService {
 
    private final StorageService storageService;
 
    public AuthenticationService(StorageService storageService) {
        this.storageService = storageService;
    }
 
    public AuthUserResponse authenticate(String login, String password) {
        UserModel user = storageService.getUserByLogin(login);
 
        if(user == null) {
            return null;
        }
 
        String passHash = DigestUtils.md5DigestAsHex(password.getBytes());
 
        if (user.getPassword().equals(passHash)) {
 
            AuthUserResponse response = new AuthUserResponse();
            response.setAuthToken(user.getUuid());
            return response;
 
        } else {
            return null;
        }
    }
}

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

AuthenticationController

, а второй —

UserController

.

Начнём с AuthenticationController. Каждый контроллер инициализируется своим сервисом, поэтому первый мы инициализируем объектом класса AuthenticationService. Также наш контроллер будет содержать маппинг на конечную точку /login. Она будет парсить пришедший запрос, вызывать метод authenticate у сервиса, и на основе результата вызова вернёт либо UUID и код 200, либо код 403 (Forbidden). Полный код контроллера:

@RestController
public class AuthenticationController {
    private final AuthenticationService authenticationService;
 
    public AuthenticationController(AuthenticationService authenticationService) {
        this.authenticationService = authenticationService;
    }
 
    @PostMapping(value = "/login", produces={"application/json"})
    public ResponseEntity<AuthUserResponse> authenticate(@RequestBody AuthUserRequest request) {
 
        String login = request.getLogin();
        String password = request.getPassword();
 
        AuthUserResponse response = this.authenticationService.authenticate(login, password);
 
        if(response != null) {
 
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.FORBIDDEN);
        }
    }
}

Второй контроллер,

UserController

, будет инициализироваться объектом класса

UserService

. Он будет содержать маппинги на конечные точки

/register

и

/{login}

. Его полный код:

@RestController
public class UserController {
 
    private final UserService userService;
 
    public UserController(UserService userService) {
        this.userService = userService;
    }
 
    @PostMapping(value = "/register", produces={"application/json"})
    public ResponseEntity<CreateUserResponse> createUser(
            @RequestBody CreateUserRequest request) {
        String login = this.userService.createUser(request);
 
        if(login != null) {
 
            CreateUserResponse response = new CreateUserResponse();
            response.setLogin(login);
 
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }
 
    @GetMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<GetUserInfoResponse> getUserInfo(
            @PathVariable("login") String login) {
        UserModel model = this.userService.getUserByLogin(login);
        if(model != null) {
            GetUserInfoResponse response = new GetUserInfoResponse();
            response.setUuid(model.getUuid());
            response.setLogin(model.getLogin());
            response.setPassword(model.getPassword());
 
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }
 
    @PutMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<Void> updateUser(
            @PathVariable("login") String login,
            @RequestBody UpdateUserRequest request) {
        boolean updated = this.userService.updateUser(login, request);
 
        if(updated) {
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .build();
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }
 
    @DeleteMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<Void> deleteUser(
            @PathVariable("login") String login) {
        boolean deleted = this.userService.deleteUser(login);
 
        if(deleted) {
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .build();
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }
 
}

На этом мы закончили разработку нашего Java-приложения. Осталось его собрать. Делается это командой:

$ mvn clean package

После сборки его можно запустить командой:

$ java -jar ./target/authentication-example-1.0-SNAPSHOT.jar

Ура, мы закончили разработку нашего сервиса! Полный его код лежит

здесь

.

Итого

  • Установили коннектор.
  • Настроили подключение к кластеру.
  • Разработали API.
  • Создали контроллеры и сервисы.
  • Собрали приложение.

Осталось протестировать сервис.

Проверка работоспособности сервиса

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

login1

и пароль

password1

.

Начнём мы с создания пользователя. Запрос будет выглядеть так:

Результат выполнения:

Теперь проверим аутентификацию:

Посмотрим данные пользователя:

Попробуем обновить пароль пользователя:

Проверим, что пароль обновился:

Удалим пользователя:

Попробуем авторизоваться:

Проверим информацию о пользователе:

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

Заключение

В качестве примера мы реализовали систему аутентификации из двух приложений:

  1. Приложение на Tarantool Cartridge, реализующее бизнес-логику работы с данными и хранилище данных пользователя.
  2. Приложение на Java, которое предоставляет нам API для аутентификации.

Tarantool Cartridge — фреймворк для масштабирования и управления кластером из нескольких экземпляров Tarantool, а также для разработки кластерных приложений.

Для взаимодействия созданных приложений мы использовали коннектор Cartridge Java, пришедший на смену устаревшему коннектору Tarantool Java. Он позволяет работать не только с одиночными экземплярами Tarantool, но и с целым кластером, что делает коннектор более универсальным и незаменимым для разработки enterprise-приложений.

Ссылки

Source link

Добавить комментарий

Ваш адрес email не будет опубликован.