Pull to refresh

MNP и появление API 1.0 — Добровольное поедание кактусов

Reading time9 min
Views5.3K

Предисловие


За время многолетнего опыта в IT у многих из Вас были мысли или даже опыт создания своего API, в которое было бы заключено все то самое полезное и ценное, а в определенный момент пазл складывается и начинается этап выращивания и поедания собственного кактуса.
После года обкатки, перед дальнейшей модификацией и развитием, хочу поделиться получившимся функционалом сервиса определения мобильного оператора по номеру телефона (только РФ), подвергнуть само API и сервис Вашей критике :)
В статье есть примеры на PHP для обкатки на своей стороне для тех, кому он будет полезен в обмен на ценные замечания ;-)

Волшебный пендель в лице отмены мобильного рабства


Еще во времена 2003-2004 года, как сейчас помню, клиент пришел заполнять бумажный бланк для пополнения счета телефона. В графе оператора пользователь указал Билайн, а до боли знакомый код 916 явно мне подсказывал, что это МТС и платеж не будет благословлен, на что клиент меня успокоил: «Этот номер перенесен к другому оператору» — сказал он.
Спустя 10 лет эта процедура была уже законодательно утверждена и, наконец, заработала, только IT-специалисты получили небольшое увеличение нагрузки в виде подготовки систем к распознаванию операторов по номеру с учетом этой переносимости номеров от оператора к оператору — MNP.

Появление данных


Как водится, у всего централизованного есть единый реестр. В данном случае их было два: один — с редко изменяемыми данными — они определяли принадлежность номеров операторам большими блоками, например, 916 ХХХ-ХХ-ХХ. Второй — с динамическими, теми самыми перенесенными номерами — то есть каждый номер был отдельной записью.
Опустим технические детали. Просто был большой пласт, поверх которого «тусили» отдельные номера — вначале лукапили оператора диапазона, затем смотрели, есть ли записи о переносе номера к другому оператору. Пласт обновлялся раз в месяц, отдельные номера — каждый час (по мере переноса номеров).

Хранение и обновление


Осознав, что подход нужен более капитальный, на коленке решено было не делать… Да и какая тут уже коленка — полноценные договора между юридическими лицами для получения доступа к динамической части базы, регламентированная синхронизация — это уже не пришить к каждому отдельному проекту, который использовал эти данные — громоздко. Явно просматривался центр — место хранения данных.
Оффтопик: немного о значимости оператора
Представьте колл-центр. Он совершает множество вызовов и применяет различные схемы выбора оператора для звонка — для звонков на номера МГТС задействуется свой стык, с мобильными — свой. Становилось все грустнее и грустнее, когда все больше и больше звонков совершалось не по тем каналам по мере миграции абонентов — ошибочно выбранный канал — это потеря вполне конкретных рублей, которые безжалостно поедали премиальный фонд сотрудников.


Появление центра — API


Набросать первые пару скриптиков, которые бы отдавали данные — дело то не хитрое. Но одно дело делать для себя, другое — для продуктов, которые находились на стороне заказчика. Тем более, в воздухе витала идея полезного приложения для Android, а это уже пахло доступом для внешнего мира, возможные атаки с целью «сломать» приложение, да и просто школота, порой, из любознательности и для самоутверждения любит что-то поломать. Поэтому, внезапно появляется явный FRONT-END, AAA, BACK-END и обработчики запросов.
Про обработчик...
Очень не хотелось, чтобы запросы клиентов API обрабатывались на лету где-то на BACK-END'е — гораздо интереснее представлялось сложить запросы на диск и обрабатывать множеством отдельных серверов, которые можно легко масштабировать. На том и сошлись.

Видимые требования:
  • Безопасная передача данных по открытым сетям
  • Отказоустойчивость
  • Стойкость к атакам от неавторизованных пользователей
  • Возможность обращения простейшим методом HTTP GET
  • Выдача в формате JSON


«Привет, Мир!» — самое сложное на самом простом примере


Вот здесь самое интересное. Обращение к API начинается с авторизации — получения токена для дальнейшего взаимодействия с системой. В качестве входных данных используются аж три идентифицирующих значения — имя пользователя, телефон, e-mail + пароль.
Шозабред!?
В нашей жизни много переменных:
  • корпоративный e-mail не дали унести с прежнего места работы
  • телефон бесконтрольно ушел в минус во время командировки и номер отнял оператор
  • пароль скомпрометирован

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

На одну учетную запись выдается только один токен в единицу времени. Его можно сменить, что вызовет «отзыв» полномочий у скриптов и приложений, которые под ним работали. Это и хорошо и плохо, но главное — контролируемо.
Заметка про токены
Один из сервисов предназначен для регистрации аккаунтов.
Представьте, что посетитель сайта хочет воспользоваться каким-то функционалом сервиса на стороннем сайте — сам сайт запросит для него новую учетку, авторизует (=получит токен), сообщит в параметрах сессий веб-браузера токен для взаимодействия с сервисом. Средствами браузера пользователь сам сможет совершать запросы, но у вас останется «руль» сессий не только внутри вашего портала, но и доступ к внешнему API вы легко закроете сменив токен, если сочтете, что доступ к информации пользователь более не должен получать.

Но прежде, мы сходим и стянем публичный ключик для взаимодействия поверх небезопасных сетей без SSL и следом подготовим данные.
Пример из практики: `SSL под запретом`
Кто знает, может у нас нет доступа по 443 портам — до сих пор помню один случай-паранойю Службы Безопасности одной большой конторы, желавшей все «прослушивать» и не пускавшей никуда по SSL, даже сервера :-/ Другого пути просто не было.

$rsa_key = file_get_contents ('http://core.api.netresult.ru/rsa_public.pem');
$client_secret = md5(time());

$data = json_encode(
	array ('request_type' => 'auth',
		'username' =>'habr_demo_20160426',
		'email' => 'support@media24-corp.ru',
		'phone' => '74995799366',
		'password' => md5('megapass'),
		'client_secret' => $client_secret)
);
$pk  = openssl_get_publickey($rsa_key);
openssl_public_encrypt($data, $encrypted, $pk);
$data = array('body' => chunk_split(base64_encode($encrypted)));


Вот такой кошмар ради простого запроса — эта куча манипуляций призвана предостеречь от изменения данных при передаче по каналам связи и для легкой валидации на FRONT-END'e.
function api_build_url (
	$scheme='https', // протокол - http|https
	$domain='core.api.netresult.ru', // домен API, общий публичный - core.api.netresult.ru
	$validation_string_prefix='s',  // строка без слешей, может быть произвольной (в соответствии с настройками)
	$validation_secret='earth', // ключик для проверка запроса, прилетающего на FRONT-END - проверяется FRONT-END`ом в составе всего URI (строка добавленная к URI)
	$data='', // POST данные. Данные ожидаются в переменной $data['body']
	$api_version='3', // версия API, к которй будет совершаться запрос
	$sla=1, // уровень SLA. Грубо - приоритет. Чем выше, тем приоритетнее. Уже сейчас можно использовать. Если при авторизации не возвращен - принять за единицу, в ином случае использовать приоритет, возвращенный при авторизации.
	$uid=0, // идентификатор пользователя по базе API (AAA). 0 при авторизации, далее использовать возвращенный UID
	$srv_name='AUTHENTICATE', // сервис, к которому происходит обращение со всеми параметрами этого сервиса
	$client_secret // Секретный ключик клиента, сгенерированный на этапе авторизации. Передается серверу один раз, далее используется API для проверки подленност запроса.
	) {
	$url_template = $scheme . '://' . $domain . '/' . $validation_string_prefix . '/__NGINX_SIGN__/__QUERY_STRING__';
	$request_string = 'v' . $api_version . '-sla' . $sla . '-uid' . $uid . '-sign__SOMESIGN__-req' . $srv_name;
	$request_string = str_replace ('__SOMESIGN__', $client_secret, $request_string);
	$request_string .= '=ts:' . time();
	echo "request_string before md5: " . $request_string . PHP_EOL;
	if (isset($data['body']) === true) {
		$request_string .= '=postmd5:' . md5($data['body']);
	}
	$request_string = str_replace($client_secret, md5($request_string), $request_string);
	$url = str_replace ('__QUERY_STRING__', $request_string, $url_template);
	$request_string .= $validation_secret;
	$query_sign = md5($request_string);
	$url = str_replace ('__NGINX_SIGN__', $query_sign, $url);
	return $url;
}


Наконец, добрались до авторизации — получаем токен и свой UID. Эту операцию можно сделать единожды за все-все время существования учетной записи, если нет нужды менять секрет/токен.
$url = api_build_url ('http', 'core.api.netresult.ru', 's', 'earth', $data, '3', 1, 0, 'AUTHENTICATE', $client_secret);
$options = array(
    'http' => array(
        'header'  => "Content-type: application/x-www-form-urlencoded\r\n",
        'method'  => 'POST',
        'content' => http_build_query($data),
    ),
);
$context  = stream_context_create($options);
$result = file_get_contents($url, false, $context);
print_r ($result);
/*
secret: 50c927316191f8678cec3b5247d1b34f
request_string before md5: v3-sla1-uid0-sign50c927316191f8678cec3b5247d1b34f-reqAUTHENTICATE=ts:1461624725
{"response":{"code":"200","data":{"uid":"36","sla":"1"},"msg":"authenticated"}}
*/


Далее учетные данные не используются — только $client_secret для подписания. Кстати, сразу отвечу на вопрос — да, можно в параметрах функции указать https для взаимодействия с использованием SSL, если запросы редкие и нет цели экономит ресурсы (или если они расходуются на стороне браузера клиента. Но понятно, что мы не отдаем клиенту функцию по авторизации, а вот запросы с токеном клиент может сам от себя выполнять по SSL при необходимости.).

Итак, авторизовались. Теперь присваиваем себе правильные UID и SLA, выполняем тестовый запрос.
$uid = $result['response']['data']['uid'];
$sla = $result['response']['data']['sla'];

echo PHP_EOL . PHP_EOL . "TEST SERVICE:" . PHP_EOL;

$url = api_build_url ('http', 'core.api.netresult.ru', 's', 'earth', '', '3', $sla, $uid, 'TEST', $client_secret);
$options = array(
    'http' => array(
        'header'  => "Content-type: application/x-www-form-urlencoded\r\n",
        'method'  => 'GET'
    ),
);
$context  = stream_context_create($options);
$result = file_get_contents($url, false, $context);
print_r ($result);
/*
TEST SERVICE:
request_string before md5: v3-sla1-uid36-sign50c927316191f8678cec3b5247d1b34f-reqTEST=ts:1461624725
{"response":{"code":"200","data":{"id":"21967","sla":"1","uid":"36","sign":"9cf6cba26113c52abea3af928f6232b6","srv":"test","req_head":"TEST=ts:1461624725","req_body":"","api_version":"3"},"msg":"ok"}}
*/

Ага, «ок»!

Извлекаем пользу


По аналогии обратимся к более полезному сервису LOOKUP_BY_NUMBER, для которого потребуются дополнительные параметры:
number - Номер телефона в международном формате (со знаком "+")
source - выбор источника (all - передавать по умолчанию),

и могут быть установлены дополнительные поля при необходимости (в мобильном приложении, например, для идентификации самих устройств и правильного формирования ответа)
v - версия клиента
did - идентификатор устройства
loc - локализация
format - формат возвращаемых значений

echo PHP_EOL . PHP_EOL . "LOOKUP_BY_NUMBER SERVICE:" . PHP_EOL;

$url = api_build_url ('http', 'core.api.netresult.ru', 's', 'earth', '', '3', $sla, $uid, 'LOOKUP_BY_NUMBER'.'=number:+79671286464=v:6=loc:ru_RU=did:000000000000000000000000=format:no=source:all', $client_secret);
$options = array(
    'http' => array(
        'header'  => "Content-type: application/x-www-form-urlencoded\r\n",
        'method'  => 'GET'
    ),
);
$context  = stream_context_create($options);
$result = file_get_contents($url, false, $context);
print_r ($result);
/*
LOOKUP_BY_NUMBER SERVICE:
request_string before md5: v3-sla1-uid36-sign0e70a22605e5a178b572f0bfe426d210-reqLOOKUP_BY_NUMBER=number:+79671286464=v:6=loc:ru_RU=did:000000000000000000000000=format:no=source:all=ts:1461625693
{"response":{"code":200,"data":{"input":{"number":{"original":"+79671286464","parsed":"79671286464"}},"route":{"default":{"ServiceProvider_id":"3425721","ServiceProvider_name":"ОАО \"Вымпел-Коммуникации\"","ServiceProvider_region":"г. Москва и Московская область"},"mnp":{"ServiceProvider_id":2,"ServiceProvider_name":"\"Мобильные ТелеСистемы\" ПАО","ServiceProvider_region":"г. Москва и Московская область"}},"active_route":{"type":"mnp"}},"msg":""}}
*/


Возвращаемые значения

Результатом выполнения запроса будет являться таблица маршрутизации в порядке приоритета. Первая запись источника — активная. Остальные — просто есть в базе, но скорее всего устаревшие. Вывод зависит от соответствующей настройки «format».
ВАЖНО: ID Сервис провайдера возвращаются внутренние для каждого источника данных! Можно безопасно консолидировать по ID внутри одного источника.

Пример вывода:
{
  "response": {
    "code": 200,
    "data": {
      "input": {
        "number": {
          "original": "+79781234567",
          "parsed": "79781234567"
        }
      },
      "route": {
        "default": {
          "ServiceProvider_id": "274959",
          "ServiceProvider_name": "Мобильные ТелеСистемы",
          "ServiceProvider_region": "Краснодарский край"
        }
      },
      "active_route": "default"
    },
    "msg": ""
  }
}


Методы отправки данных

Можно использовать простейший метод GET, пример:
http://core.api.netresult.ru/s/f62bb062e30b01e7a689c8cdfdf10577/v3-sla1-uid0-signbb52d15783f7ed30df5d0e7a618cc048-reqLOOKUP_BY_NUMBER=number:+79781234567=v:6=loc:ru_RU=did:000000000000000000000000=format:no=source:all

а можно отправлять значения переменных и в POST-данных с шифрованием по примеру авторизации выше.

Пример работающего на базе API мобильного приложения для Android с минимальным функционалом самого приложения.
image
.

Комментарии и отзывы


Очень признателен за любую критику в отношении всего-всего, что сочтете нужным прокомментировать!
Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments7

Articles