Как мы в Fix Price внедряли систему Keycloak

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Привет,Хабр! Меня зовут Вадим Райский, и я работаю руководителем IT-проектов для департамента управления магазинами Fix Price. Сегодня расскажу о том, как мы в Fix Price закрыли проблему организации единой авторизации и аутентификации для наших сервисов с помощью Keycloak. Хотелось бы, чтобы эта статья оказалась полезной для всех, кто планирует внедрять это решение.

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

О проблемах и задачах

До недавних пор мы использовали самописные решения — аутентификацию и авторизацию по LDAP с раздачей ролей и прав. Это позволило закрыть основные потребности, однако проблемы с централизованным доступом всё равно оставались, а главное было вот в чём:

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

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

В общем, было принято решение создать микросервис, который бы авторизовывал пользователя и мог возвращать его группы доступа и другую информацию. Также была необходима SSO, бесшовная авторизация. Сервис должен иметь возможность добавлять новые учетные записи, которые сразу же хранились бы в едином сервисе, и у нас был бы доступ к управлению ими, а также нужна была синхронизация с Active Directory (далее просто AD).

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

Особенности внедрения Keycloak

То, что понравилось после первой же интеграции: поставили Keycloak прямо из коробки, без какой-либо доработки «напильником». Понятно, что нужно было подготовить сами системы для интеграции, правильно настроить сам Keycloak, но всё прошло достаточно гладко. А масштабное внедрение продолжается и до сих пор. Дело в том, что у нас есть ряд разработанных систем, которые мы хотим интегрировать с Keycloak в первую очередь.

Что касается первичной интеграции, то на первую систему у нас ушло порядка трех недель разработки и еще несколько недель тестирования. Это ведь был для нас совершенно новый подход к авторизации и аутентификации (обе теперь работают через Keycloak), поэтому нужно было провести полный тест по всей системе. И когда появился первый реальный опыт, первая успешная система с внедрением, мы постепенно начали распространять эту практику. Сейчас мы внедряем Keycloak в другие наши системы. Также думаем об интеграции с Gitlab и Rancher, что тоже возможно, но необходимо обновить сам Гитлаб.

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

О потенциальных трудностях и их решении

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

  • Например, у нас была подключена библиотека, которая конфликтовала с библиотекой Keycloak (kreait/firebase-php с stevenmaguire/oauth2-keycloak). Поэтому нам пришлось переключиться на другую.

  • Еще одна проблема заключалась в тестировании всех групп пользователей системы, а многие пользователи у нас не были зарегистрированы через AD. Это, например, пользователи, которых не было в нашей системе AD: они проверялись через базу данных. Соответственно, нам нужно было заводить в Keycloak и их.

  • Следующая трудность — протестировать все системы, куда интегрирован Keycloak, под всеми ролями, что отняло у нас львиную долю времени.

О масштабах и сервисах

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

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

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

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

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

Мы уже объединили в единую систему авторизации 7 сервисов Fix Price, у части из которых есть региональные инстансы. В ближайшее время планируем интегрировать еще несколько сервисов: например, Gitlab и те, которые разрабатываем сами. Кроме того, в планах внедрить еще порядка 6 сервисов, к которым мы пока не приступали. Из примеров на ближайшую перспективу: личный кабинет сотрудника, а также сервис, который создан для заведующих (о нём уже говорил выше).

О плюсах Keycloak

Одна из причин, по которой мы выбрали Keycloak — количество сервисов, поскольку здесь у него ограничений нет. Конечно, теоретически могут возникнуть ограничения совместимости с Keycloak только со стороны какого-то сервиса, который мы планируем внедрить, но на практике пока с таким не сталкивались. А единственный момент, которого опасаемся всерьез — это то, как система справится с кратным увеличением количества пользователей.

Адаптеры позволяют использовать Keycloak с различными языками (Java, PHP, Node.js и др.) и фреймворками как в виде классического OpenID, так и в SPA-приложениях (JavaScript Adapter). А гибкая и продуманная структура ролей и других настроек обеспечивает широкие возможности для применения системы.

Также добавлю Keycloak — это опенсорсная реализация SSO на Java, Production-ready. Она довольно легкая (около 60 МБ), начальный образ микросервиса весит около 400 МБ. Развивается Keycloak силами специалистов Red Hat, которые в особом представлении не нуждаются.

О подводных камнях

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

Менее существенная проблема — неудобства интерфейса админки. Функционал там не очень гибкий, много действий приходится делать вручную. Например, если проект разворачивается на тестах, приходится дублировать те же самые действия на проде. Также можно отметить невозможность значительной кастомизации интерфейса (например, ЛК для пользователей). Здесь доступно только использование шаблонов на основе Freemarker Templates.

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

Об альтернативах

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

Примеры интеграции

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

Пример №1

Производилась интеграция во внутренний проект компании, реализованный на фреймворке Yii2. Для работы использовалась библиотека stevenmaguire/oauth2-keycloak. Данный проект не имеет REST API, поэтому аутентификация будет производиться через механизм сессий.

Т.к. в проекте уже присутствовала аналогичная авторизация по токену, потребовалось добавить только поля refresh_token и token_expires_at:

$this->addColumn(
    'ad_user_logged_in',
    'refresh_token',
    $this->text()->comment('Refresh токен')->after('token')
);
$this->addColumn(
    'ad_user_logged_in',
    'token_expires_at',
    $this->dateTime()->comment('Дата истечения токена')->after('token')
);

Для работы потребуется сконфигурированный класс Stevenmaguire\OAuth2\Client\Provider\Keycloak, сервис AuthService, а также аутентификатор KeycloakAuth. Добавим их в конфиг DI:

'definitions' => [
    Keycloak::class => [
        [],
        [
            'options' => [
                // Адрес авторизации сервиса Keycloak
                'authServerUrl' => Env::keycloakAuthUrl(),
                // Пространство имен в Keycloak
                'realm' => Env::keycloakRealm(),
                // ID клиента
                'clientId' => Env::keycloakClientId(),
                // Адрес страницы авторизации внутри проекта
                'redirectUri' => Env::frontendUrl() . '/auth/login',
            ],
        ]
    ],
    AuthService::class => [],
    KeycloakAuth::class => [],
],

Теперь генерируем ссылку для авторизации в Keycloak и перенаправляем по ней пользователя. При этом сохраняем текущее состояние сессии:

$authUrl = $this->keycloak->getAuthorizationUrl();
$this->session[$this->stateParam] = $this->keycloak->getState();
Yii::$app->getResponse()->redirect($authUrl);

После возвращения пользователя из Keycloak с кодом авторизации, первым делом проверяем сессию:

$sessionState = $this->request->get('state');
if (
    !$this->session->has($this->stateParam)
    || $sessionState !== $this->session[$this->stateParam]
) {
    throw new Exception('Некорректный запрос');
}

Далее получаем токен, refresh-токен, данные пользователя из Keycloak, сохраняем и логиним пользователя:

$token = $this->keycloak->getAccessToken('authorization_code', ['code' => $code]);
$kkUser = $this->keycloak->getResourceOwner($token)->toArray();
if (!$user = User::findOne(['login' => $kkUser['preferred_username']])) {
    $user = new User();
}
$user->setAttributes([
    'login' => $kkUser['preferred_username'],
    'roles' => $kkUser['roles'],
    'token' => (string)$token,
    'refresh_token' => $token->getRefreshToken(),
    'token_expires_at' => (new DateTime())
                            ->setTimestamp($token->getExpires())
                            ->format('Y-m-d H:i:s'),
    'email' => $kkUser['email'],
    'username' => $kkUser['name']
]);

$user->save();
Yii::$app->user->login($user);

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

$token = $this->keycloak->getAccessToken(
    'refresh_token',
    ['refresh_token' => $user->refresh_token]
);
$user->token = (string)$token;
$user->refresh_token = $token->getRefreshToken();
$user->token_expires_at = $this->getTokenExpires($token);
$user->save();

Для выхода из системы потребуется перенаправить пользователя по адресу Logout URL:

Yii::$app->user->logout();
Yii::$app->getResponse()->redirect($this->keycloak->getLogoutUrl());

Проверка актуальности токена производится методом authenticate класса KeycloakAuth:

public function authenticate($user, $request, $response)
{
    $userIdentity = $user->getIdentity();
    $now = new Datetime(
        (new Query())
            ->select((new Expression('NOW()')))
            ->scalar()
    );
    $tokenExpiresAt = new DateTime($userIdentity->token_expires_at);

    if ($now >= $tokenExpiresAt) {
        $this->authService->refreshToken($userIdentity);
    }
    return $userIdentity;
}

В завершение привязываем аутентификатор к требуемым контроллерам:

'authenticator' => [
    'class' => KeycloakAuth::class,
],

Пример №2

Во Frontend-приложении на Vue.js мы использовали библиотеку keycloak-js. В скрипте main.js настроили инициализацию по параметрам пользователя и переменным сервера Keycloak — url, realm, clientId для авторизации по токену:

keycloak.init({onLoad: initOptions.onLoad}).then((auth) => {
    if (!auth) {
        window.location.reload();
    }
    store.dispatch('initUser', initUserFromKeycloak(keycloak))
        .then(() => {
            (new Vue({
                router,
                store,
                render: h => h(App)
            })).$mount('#app');
        })
        .catch(err => (console.log(err)))
});

Также по заданному интервалу времени (у нас это 70 секунд) выполняется обновление и валидация авторизационного токена пользователя с помощью той же функции, что и для первичной авторизации. Далее токен расшифровывается, и получаем необходимые нам данные из Keycloak о пользователе: набор его ролей, логин, username, email, token.

Инстансы приложения для разных стран развернуты отдельно, следовательно в одном реалме (Realm — домен политики безопасности в приложении) созданы разные клиенты под свою локаль. Также наличие разных клиентов было актуально во время тестирования, так как для отдельных задач разработки в некоторых случаях удобно было иметь отдельные клиенты без необходимости переназначать данные url развернутых тестовых стендов при каждой проверке. Важно было динамически получать данные из токена, зависимые от clientId. Для этого clientId помещался в переменную среды, а данные получались с помощью такой конструкции:

roles = keycloak

    .tokenParsed

    .resource_access[initOptions.clientId].roles;

Полученные данные записываются в состояние (state) auth store и local storage. Если все проходит успешно, то позволяет дальше работать с приложением и добавляет скрытый iframe для проверки сессии в Keycloak.

При каждом запросе в серверное приложение с фронта будет уходить access_token, который бэк проверит через REST API в Keycloak. Чтобы не отправлять в Keycloak частые запросы, создан кэш авторизации на одну минуту. Если токен подтвердится, то запрос с фронта выполнится и вернет данные. Проверка выполняется в классе, реализующем yii\web\IdentityInterface:

class User extends Model implements IdentityInterface

Это позволяет проверять на бэке доступность запросов для определенного пользователя по его ролям с помощью поведения behaviors. Для сообщения бэкенда с Keycloak используется библиотека stevenmaguire/oauth2-keycloak. Реализовано это с помощью функции findIdentityByAccessToken.

public static function findIdentityByAccessToken($token, $type = null)
{
    $user = App::cache()->getOrSet(
        $token,
        function () use ($token) {
            return Yii::$container->get(Keycloak::class)
                ->getResourceOwner(new AccessToken(['access_token' => $token]))
                ->toArray();
        },
        self::TOKEN_CACHE_LIFETIME
    );

    return new User(
        [
            'login' => $user['preferred_username'],
            'user_id' => $user['preferred_username'],
            'roles' => $user['roles'],
            'token' => $token,
            'email' => $user['email'],
            'username' => $user['name'],
        ]
    );
}

Пример №3

В Yii2 в аутентификации участвует компонент \yii\web\User. Внутри имеет метод loginByAccessToken, в теле которого идёт вызов статического метода findIdentityByAccessToken класса, реализующего интерфейс yii\web\IdentityInterface.

Для интеграции инструмента Keycloak на backend стороне был подключён пакет stevenmaguire/oauth2-keycloak.

В проекте было также решено избавиться от вызова статических методов, таким образом был переопределён метод \yii\web\User::loginByAccessToken:

public function loginByAccessToken($token, $type = null): IdentityInterface|null
{
    $identity = $this->authService->getIdentityByAccessToken($token, $type);
    if ($identity && $this->login($identity)) {
        return $identity;
    }
       
    return null;
}

Ранее в строке получения $identity вызывался статический метод класса Identity, теперь метод более не актуален и имеет заглушку:

public static function findIdentityByAccessToken($token, $type = null): ?IdentityInterface
{
    return null;
}

Был добавлен класс AuthService, реализующий методы интерфейса AuthServiceInterface:

interface AuthServiceInterface
{

    public function getIdentityByAccessToken(mixed $token, mixed $type = null): ?IdentityInterface;
    public function getUserInfoByToken(string $token): array;
}

final class AuthService implements AuthServiceInterface
{
    use Cacheable;

    public function __construct(
        private readonly Keycloak $keycloak,
        private readonly Cache $cache
    ) {
    }

    private const TOKEN_CACHE_LIFETIME = 60;

    public function getIdentityByAccessToken(mixed $token, mixed $type = null): ?IdentityInterface
    {
        $userInfo = $this->getUserInfoByToken($token);

        return new User([
            'id' => $userInfo['preferred_username'],
            'username' => $userInfo['preferred_username'],
            'name' => $userInfo['name'],
            'roles' => $userInfo['roles'],
        ]);
    }

    public function getUserInfoByToken(mixed $token): array
    {
        if ($userInfo = $this->getCache()->get($token)) {
            return $userInfo;
        }

        $userInfo = $this->getInfoFromKeycloak($token);

        $this->getCache()->set($token, $userInfo, self::TOKEN_CACHE_LIFETIME);

        return $userInfo;
    }

    private function getInfoFromKeycloak(string $token): array
    {
        $userInfo = $this->keycloak->getResourceOwner(new AccessToken(['access_token' => $token]))
            ->toArray();

        $this->validateUserInfo($userInfo);
        $this->normalizeUserInfo($userInfo);

        return $userInfo;
    }

    private function validateUserInfo(array $userInfo): void
    {
        if (!isset($userInfo['preferred_username'])) {
            throw new Exception('Массив "userInfo" не содержит обязательный ключ "preferred_username".');
        }
        if (!isset($userInfo['name'])) {
            throw new Exception('Массив "userInfo" не содержит обязательный ключ "name".');
        }
    }

    private function normalizeUserInfo(array &$userInfo): void
    {
        $userInfo['roles'] = $userInfo['roles'] ?? [];
    }
}

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

Пример №4

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

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

<?php

require_once("config.php");
include_once("ldap.php");
include_once("auth_by_database.php");

if ($logout) {
    session_destroy();
    redirectTo('На главную', '/');
}

if ($userToken && $userToken != 0) {
    include_once('main_page.php');
} elseif ($login && $password) {
    if (checkAuthLdap($login, $password) && checkAuthDatabase($login, $password)) {
        include_once('main_page.php');
    } else {
        $error = 'Логин или пароль неверный';
        include('auth_form.php');
    }
} else {
    include('auth_form.php');
}

Мы переделали процесс аутентификации, для чего реализовали класс, который работает непосредственно с Keycloak Api, базой данных и сессией. И вот что получилось:

<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/class/LegacyKeyCloakAuth.php';

try {
    $auth = new LegacyKeyCloakAuth(
        $dbcnx,
        Config::$app->keycloak['host'],
        Config::$app->keycloak['realm'],
        Config::$app->keycloak['client_id'],
        Config::$app->keycloak['client_secret'],
        Config::$app->keycloak['redirect_url']
    );
    if ($logout) {
        $auth->logout();
        redirectTo('Выход', $auth->getLoginUrl());
    } elseif ($auth->isAuthorized()) {
        include_once('main_page.php');
    } elseif (!isset($queryParams['code'])) {
        redirectTo('Вход', $auth->getLoginUrl());
    } elseif ($auth->authorize($queryParams['code']) && $auth->isAuthorized()) {
        include_once('main_page.php');
    } else {
        include_once('error_page.php');
    }
} catch (\Exception $e) {
    exit($e->getMessage());
}

Технические моменты

И еще немного подробностей. Ранее данные о пользователе получались исключительно через Active Directory manager, но теперь от этого отошли, а ADManager используем для быстрого получения данных о нужном пользователе (не текущем авторизованном) по его UserId.

До внедрения Keycloak при механизме авторизации через AD для проведения действий по проверке и исправлению различных ошибочных менеджерских операций требовалась роль суперпользователя (SuperUser). При входе в сервис под пользователем с этой ролью была возможность из интерфейса переключиться на любого другого пользователя сервиса и выполнить действия от его имени. После релиза Keycloak такая возможность стала недоступной — по политике безопасности такое нельзя выполнить из интерфейса приложения. Но в самом Keycloak все же предусмотрена такая возможность — в консоли администратора в списке пользователей есть действие Impersonate, которое позволяет выполнить авторизацию и управление сервисом под выбранным пользователем.

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

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

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

Но впереди еще много работы: нужно переделать систему авторизации, избавиться от ролей на стороне базы данных легаси приложения и перейти на более тесную интеграцию с Keycloak. Также нужно решить проблемы зависимости от состояния БД и рассинхронизации ролей разрешений на стороне AD.

Вместо заключения

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

Источник: https://habr.com/ru/company/fix_price/blog/695612/


Интересные статьи

Интересные статьи

О новой миссии под названием Advanced Composite Solar Sail System (ACS3) NASA сообщило на своем сайте 23 июня этого года. В ходе эксперимента будут проверены новые композ...
Интегрируя Keycloak в уже существующую систему, высока вероятность столкнуться с необходимостью во время аутентификации загружать пользователей из древней базы данных, где информация о ни...
Мы сильно обрадовались новому контракту и уже представляли, как логотип клиента приукрасит наше портфолио. Но все оказалось не так радужно. Расскажем, как мы работали с дочкой крупной...
«Битрикс» — кошмар на костылях. Эта популярная характеристика системы среди разработчиков и продвиженцев ныне утратила свою актуальность.
Объединение учетных систем удаленного филиала и их интеграция с головной структурой — задача достаточно непростая даже в пределах России. А когда заказчик находится за рубежом, весь проект может ...