Предисловие
Передо мной стояла задача по интеграции нашего сервиса с госуслугами. Казалось ничего сложного не предстоит, но учитывая что наш сервис базируется на технологии ASP.NET всё было не так оптимистично. В начале были поиски.. много поисков, которые привели к множеству разрозненной и чаще всего неактуальной информации. Так же были найдены уже готовые решения, но как заявляли некоторые товарищи на форумах за такое могут и по головке погладить. Поэтому было решено писать самому.
Эта статья скорее больше актуализация и дополнение информации из этой статьи.
Введение
На сайте Минцифр есть методичка максимально раздутая и очень запутанная, но пользоваться её нам всё равно придётся. Мы будем работать с ЕСИА версии 3.11 (актуальная на момент написания статьи). Кратко наши действия заключаются вот в чем:
-
Регистрация ИС в регистре информационных систем ЕСИА
-
Регистрация ИС в тестовой среде
-
Выполнение доработки системы для взаимодействия с ЕСИА
Звучит довольно просто, но каждый шаг целая отдельная история приключений. Регистрация ИС в ЕСИА приключение для бюрократа. Поэтому в этой статье мы немного посмотрим на второй шаг, и детально распишем реализацию.
Содержание
Всё необходимое
КриптоПРО CSP + КриптоПРО .Net + КриптоПРО .NetSDK. Всё это можно скачать с офф. сайта КриптоПРО. На время разработки лучше использовать триал версию.
Наш инвентарь для путешествия:
-
КриптоПРО CSP
-
КриптоПРО .Net
-
КриптоПРО .NetSDK
-
Контейнер закрытого ключа с сертификатом нашей организации
-
Много терпения
Немного о КриптоПРО CSP + .Net Core 5+
Вот тут и начинаются первые проблемы. На момент написания статьи у КриптоПРО .Net нет поддержки .Net Core 5 и выше. Есть сборка под .Net Core 3.1 но и она выглядит сомнительно. Поэтому было решено поднять сервис для .Net Framework 4.8 который будет использовать средства КриптоПРО CSP для подписания с использованием экспортировать контейнер с такого токена запрещено ФНС. Поэтому необходимо заранее получить токен на имя сотрудника с экспортируемым контейнером. Так как его необходимо будет скопировать на сервер.
Приступаем
Начнём с того, что вы уже отправили заявку регистрации ИС в ЕСИА и её приняли. А так же отправили заявка на тестовую среду. Приступим к этапу настройки ИС в тестовом кабинете электронного правительства. Вот ссылка на тестовую страницу. Логинимся под тестовой учетной записью тестового пользователя 006(все данные лежат в приложении к работе с тестовой средой), так как он имеет доступ к управлением ИС.
Здесь ищем нашу систему по Мнемонике или полному названию, если таковой нет то создаём. Напротив нашей системы есть две кнопки:
Первая кнопка — изменить нашу ИС (информация о ИС, редиректы и тд)
Вторая кнопка — наши сертификаты с помощью которых мы подписываем сообщения в ЕСИА
Настройка ИС
Есть важный момент в настройки ИС. Это URL системы. Тут мы указываем ссылки куда ЕСИА может делать переадресацию при запросе от нашей ИС. На эти точки будет приходить авторизационный код (Если он указан в запросе).
Сертификаты ИС
Здесь мы можем загрузить наш сертификаты или же удалить их. Есть один важный момент, каждая ИС может иметь только один уникальный сертификат. А связи с тем, что на тестовой среде все системы регистрируются под одним пользователем и сертификаты тестовые одни на всех часта такая ситуация, что кто-то удаляет у вас сертификат и загружает к себе. А ваши запросы теперь падают с ошибкой) Но если у вас уже готов ЭЦП на сотудника, то лучше используйте её.
Реализуем
Мы закончили с настройки нашей ИС и можем приступить к реализации. Надеюсь вы уже установили КриптоПРО и всё необходимое для него. Если нет, я подожду…
Устанавливаем сертификаты
Такс~ Всё готово. Качаем сертификаты по ссылке из методички. Специально не буду вставлять, так как может измениться.
Здесь нам интересен сертификат ТЕСИА ГОСТ 2012.cer — это сертификат с помощью которого ЕСИА подписывает сообщения отправляя в нашу ИС. (Соответственно для продуктовой среды свой сертификат). Устанавливаем сертификат как доверенный. Здесь ничего сложного думаю разберётесь.
Теперь устанавливаем тестовый контейнер и сертификат. Для примера будем использовать предоставленные ЕСИА контейнеры, но вы можете использовать свои. Всё это лежит внутри архива.
В архиве лежит папка d1f73ca5.000 — это контейнер нам необходимо его переместить по пути C:\Users\User\AppData\Local\Crypto Pro
Теперь открываем КриптоПРО CSP. Выбираем установить личный сертификат и указываем Тестовое ведомство Фамилия006 ИО.cer и нажимаем найти автоматически. Выполняем оставшиеся шаги сами.
Механизм подписания
Пожалуй начинается самая важная и самая запутанная часть всего пути. Здесь мы реализуем сервис для работы с подписью. И так делаю выжимку из методических материалов, чтобы Вам не пришлось читать много текста.
Для получения авторизационный ссылки — ссылка на которую мы будем переадресовывать пользователя для авторизации в ЕСИА. Нам необходимо собрать ссылку из параметров.
-
client_id
— наша Мнемоника -
client_secret
— Отсоединённая подпись от параметров запроса в кодировке UTF-8 -
redirect_uri
— ссылка на которую ЕСИА будет переадресовывать пользователя вместе с авторизационным кодом -
scope
— перечень запрашиваемой информации. Напримерfullname birthdate gender
-
response_type
— тип ответа от ЕСИА, в нашем случае это просто строчкаcode
-
state
— Идентификатор текущего запроса. Генерируется таким образомGuid.NewGuid().ToString("D");
-
timestamp
— время запроса авторизационного кода в формате yyyy.MM.dd HH:mm:ss Z. Генерируется таким образомDateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");
-
client_certificate_hash
— это fingerprint сертификата в HEX-формате.
Обозначили наш зоопарк. Самый важный зверь здесь client_secret
Получаем client_certificate_hash
В методическом указании от Минцифр есть ссылка на специальную утилиту с помощью которой мы можем получить этот хэш. Разархивировали архив и видем перед нами sh. Windows пользователи не пугаемся, на самом деле тут же лежит .exe файл. Чтобы вычислить хэш нашего сертификат просто необходимо из cmd запустить вот такой скрипт:
cpverify.exe test.cer -mk -alg GR3411_2012_256 -inverted_halfbytes 0
Формирование client_secret
Такс перед тем как просто получит client_secret
нам необходимо сделать:
-
ASP.Net Framework 4.8 WebAPI — тот самый сервис который будет работать с КриптоПРО CSP
Пропустим множество шагов создания этого сервиса и перейдём сразу к его настройки для работы с КриптоПРО CSP.
Настройка сервиса для работы с КриптоПРО CSP
Добавляем ссылки на DLL КриптоПРО.
Переходим по пути C:\Program Files (x86)\Crypto Pro.NET SDK\Assemblies\4.0
Выбираем всё что нам нужно. (подробная информация)
Теперь мы имеем доступ к API КриптоПРО CSP из кода .Net Framework
Теперь создаём контроллер:
Код контроллера
Итак нам необходимо получать строку для подписания. Создадим метод
const string CertSerialNumber = "01f290e7008caed0904b967783fd0e4ad6"; const string EsiaCertSerialNumber = "0125657e00a1ae59804d92116214e53466"; [HttpGet] public string Get(string msg) { msg = Base64UrlEncoder.Decode(msg); var data = Encoding.UTF8.GetBytes(msg); var client_secret = Sign(data); return client_secret; }
Мы заранее укажем константами серийные номера сертификатов.
В методе Get получаем строку в Base64Url формате, чтобы спокойно передавать наши длинные сообщения.
Декодируем строку из Base64Url в текст. После чего переводим текст в байты используя UTF-8. А теперь подписываем.
string Sign(byte[] data) { var gost3411 = new Gost3411_2012_256CryptoServiceProvider(); var hashValue = gost3411.ComputeHash(data); gost3411.Clear(); var signerCert = GetSignerCert(); var SignedHashValue = GostSignHash(hashValue, signerCert.PrivateKey as Gost3410_2012_256CryptoServiceProvider, "Gost3411_2012_256"); var client_secret = Base64UrlEncoder.Encode(SignedHashValue); return client_secret; }
И так что мы тут делаем. С помощью ГОСТ 34.11-2012 мы вычисляем хэш нашего сообщения. И используя полученный сертификат подписываем сообщение.
X509Certificate2 GetSignerCert() { var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); var certificates = store.Certificates.Find(X509FindType.FindBySerialNumber, CertSerialNumber, false); if (certificates.Count != 1) { return null; } var certificate = certificates[0]; if (certificate.PrivateKey == null) { return null; } return certificate; }
Здесь мы открываем наш склад с контейнерами и ищем именно тот где лежит наш сертификат. После чего извлекаем из него сертификат.
byte[] GostSignHash(byte[] HashToSign, Gost3410_2012_256CryptoServiceProvider key, string HashAlg) { try { //Создаем форматтер подписи с закрытым ключом из переданного //функции криптопровайдера. var Formatter = new Gost2012_256SignatureFormatter( (Gost3410_2012_256CryptoServiceProvider) key); //Устанавливаем хэш-алгоритм. Formatter.SetHashAlgorithm(HashAlg); //Создаем подпись для HashValue и возвращаем ее. return Formatter.CreateSignature(HashToSign); } catch (CryptographicException e) { Console.WriteLine(e.Message); return null; } }
С помощью этого кода как раз и создаётся наша подпись на хэш строки. Здесь используется ГОСТ 34.10-2012.
Итак контроллер готов. Теперь переходим в наш основной проект на .Net Core
Создаём строку подписания. Просто выполняем конкатенацию параметры без разделителей. Здесь я использую IOptions чтобы брать параметры из appsettings.json.
var msg = $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUri}";
Мы получил строку для подписания. Теперь нам необходимо эту строку закодировать в Base64Url и отправляем её на подписание в написанный нами заранее сервис
private string GetClientSecret(string msg){ var client = new HttpClient(); var msgBase64 = Base64UrlEncoder.Encode(msg); var response = await client.GetAsync($"{cryptoProSettings.Value.BaseUrl}/Get?msg={msgBase64}"); var clientSecret = await response.Content.ReadAsStringAsync(); clientSecret = JsonConvert.DeserializeObject<string>(clientSecret); return clientSecret; }
Собираем ссылку для авторизации в Госуслугах
Наконец-то мы получили этот долгожданный секрет. Но вы могли бы подумать это всё, дальше всё просто и ясно. Не тут то было! Дело в том, что ЕСИА требует Base64 Url Safe кодироку. И она немного отличается от Base64Url кодировки доступной из коробки .Net
Итак дело за малым, собираем нашего гомункула из секрета и параметров.
Класс помощник для сборки ссылки
Возможно излишне, но мне понравился метод сбора вот таким способом.
public class RequestBuilder { List<RequesItemClass> items = new List<RequesItemClass>(); public void AddParam(string name, string value) { items.Add(new RequesItemClass { name = name, value = value }); } public override string ToString() { return string.Join("&", items.Select(a => a.name + "=" + a.value)); } } public class RequesItemClass { public string name; public string value; }
Код сборки ссылки
async Task<string> UrlBuild(string redirectUri) { using var client = new HttpClient(); var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000"); var state = Guid.NewGuid().ToString("D"); var msg = $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUri}"; var clientSecret = await GetClientSecret(msg); var builder = new RequestBuilder(); builder.AddParam("client_secret", clientSecret); builder.AddParam("client_id", esiaSettings.Value.ClientId); builder.AddParam("scope", esiaSettings.Value.Scope); builder.AddParam("timestamp", timestamp); builder.AddParam("state", state); builder.AddParam("redirect_uri", redirectUri); builder.AddParam("client_certificate_hash", esiaSettings.Value.ClientCertificateHash); builder.AddParam("response_type", "code"); builder.AddParam("access_type", "online"); //Вот тут самый важный момент на который было потрачено множество времени. Просто заменяем символы на безопасные var url = esiaSettings.Value.EsiaAuthUrl + "?" + builder.ToString().Replace("+", "%2B") .Replace(":", "%3A") .Replace(" ", "+"); return url; }
Получаем ссылку на подобии вот такой:
Здесь https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v2/ac
ссылка на конечную точку получения авторизационно кода, указана в методическом материале.
https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v2/ac?client_secret=v_c33_-LpkyKJbopTEYqBMbGZrBy9r9u1pzbRmMLNlJPcBnPTJj6Xx5DuxXba3EZZoXdMsb0YIwPDCoF0dfYjQ&client_id=MEMONIKA&scope=fullname+birthdate+gender×tamp=2022.12.23+16%3A37%3A45+%2B0000&state=3a19c4d7-594b-496f-aa6e-970c75a925a4&redirect_uri=https%3A//api.site/users/esia&client_certificate_hash=EED1079A4FF154E117EAA196DCB551930807825DE1DE15EAF7607F354BA47423&response_type=code&access_type=online
Теперь перенаправляем пользователя по этой ссылке и ожидаем пока он авторизуется. После авторизации ЕСИА переадресует его на нашу ссылку и отправит туда в виде аргументов авторизационный код и state.
Получение токена доступа
Теперь время получить токен взамен на авторизационный код.
Метод для получение токена
public async Task<EsiaAuthToken> GetToken(string authorizationCode, string redirectUrl) { var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000"); var state = Guid.NewGuid().ToString("D"); var msg = $"{esiaSettings.Value.ClientId}{esiaSettings.Value.Scope}{timestamp}{state}{redirectUrl}{authorizationCode}"; var clientSecret = await GetClientSecret(msg); var requestParams = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("client_id", esiaSettings.Value.ClientId), new KeyValuePair<string, string>("code", authorizationCode), //Здесь мы передаём полученный код new KeyValuePair<string, string>("grant_type", "authorization_code"), //Просто указываем тип new KeyValuePair<string, string>("state", state), new KeyValuePair<string, string>("scope", esiaSettings.Value.Scope), new KeyValuePair<string, string>("timestamp", timestamp), new KeyValuePair<string, string>("token_type", "Bearer"), //Какой токен мы хотим получить new KeyValuePair<string, string>("client_secret", clientSecret), new KeyValuePair<string, string>("redirect_uri", redirectUrl), new KeyValuePair<string, string>("client_certificate_hash", esiaSettings.Value.ClientCertificateHash) }; using var client = new HttpClient(); using var response = await client.PostAsync(esiaSettings.Value.EsiaTokenUrl, new FormUrlEncodedContent(requestParams)); response.EnsureSuccessStatusCode(); var tokenResponse = await response.Content.ReadAsStringAsync(); var token = JsonConvert.DeserializeObject<EsiaAuthToken>(tokenResponse); if (!await ValidatingAccessToken(token)) { throw new Exception("Ошибка проверки маркера индентификации"); } return token; }
Тут всё простенько, снова генерируем client_secret
указываем остальные параметры и отправляем запрос в ЕСИА на получение токена. Тестовый Uri https://esia-portal1.test.gosuslugi.ru/aas/oauth2/v3/te
Класс токена
public class EsiaAuthToken { /// <summary> /// Токен доступа /// </summary> [JsonProperty("access_token")] public string AccessToken { get; set; } /// <summary> /// Идентификатор запроса /// </summary> public string State { get; set; } string[] parts => AccessToken.Split('.'); /// <summary> /// Хранилище данных в токене /// </summary> public EsiaAuthTokenPayload Payload { get { if (string.IsNullOrEmpty(AccessToken)) { return null; } if (parts.Length < 2) { throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}"); } var payload = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(parts[1])); return JsonConvert.DeserializeObject<EsiaAuthTokenPayload>(payload); } } /// <summary> /// Сообщение для проверки подписи /// </summary> [Newtonsoft.Json.JsonIgnore] public string Message { get { if (string.IsNullOrEmpty(AccessToken)) { return null; } if (parts.Length < 2) { throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}"); } return parts[0] + "." + parts[1]; } } /// <summary> /// Сигнатура подписи /// </summary> [Newtonsoft.Json.JsonIgnore] public string Signature { get { if (string.IsNullOrEmpty(AccessToken)) { return null; } if (parts.Length < 2) { throw new Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}"); } return parts[2]; } } public class EsiaAuthTokenPayload { [JsonConstructor] public EsiaAuthTokenPayload(string tokenId, string userId, string nbf, string exp, string iat, string iss, string client_id) { TokenId = tokenId; UserId = userId; BeginDate = EsiaHelper.DateFromUnixSeconds(double.Parse(nbf)); ExpireDate = EsiaHelper.DateFromUnixSeconds(double.Parse(exp)); CreateDate = EsiaHelper.DateFromUnixSeconds(double.Parse(iat)); Iss = iss; ClientId = client_id; } /// <summary> /// Идентификатор токена /// </summary> [JsonProperty("urn:esia:sid")] public string TokenId { get; private set; } /// <summary> /// Идентификатор пользователя /// </summary> [JsonProperty("urn:esia:sbj_id")] public string UserId { get; private set; } /// <summary> /// Время начала действия токена /// </summary> [JsonPropertyName("nbf")] public DateTime BeginDate { get; private set; } /// <summary> /// Время окончания действия токена /// </summary> [JsonPropertyName("exp")] public DateTime ExpireDate { get; private set; } /// <summary> /// Время выпуска токена /// </summary> [JsonPropertyName("iat")] public DateTime CreateDate { get; private set; } /// <summary> /// Организация, выпустившая маркер /// </summary> [JsonPropertyName("iss")] public string Iss { get; private set; } /// <summary> /// Адресат маркера /// </summary> [JsonPropertyName("client_id")] public string ClientId { get; private set; } } } public static class EsiaHelper { public static DateTime DateFromUnixSeconds(double seconds) { var date = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); return date.AddSeconds(seconds).ToLocalTime(); } }
Проверка токена
Итак помимо того, что нам нужно получить токен, нам так же необходимо проверить его.
Сам токен состоит из 3 частей.
1 часть — заголовок JWT токена
2 часть — payload токена, там вся основная информация о токене
3 часть — RAW подпись в формате UTF-8
Код конечной точки для проверки подписи
[HttpPost] public bool Verify(VerifyMessage message) { try { return VerifyRawSignString(message.Message, message.Signature); } catch (Exception ex) { return false; } } public class VerifyMessage { public string Signature { get; set; } public string Message { get; set; } }
Код проверки подписи на нашем сервисе
/// <summary> /// Проверка подписи JWT в формате HEADER.PAYLOAD.SIGNATURE. /// </summary> /// <param name="message">HEADER.PAYLOAD в формате Base64url</param> /// <param name="signature">SIGNATURE в формате Base64url</param> bool VerifyRawSignString(string message, string signature) { var signerCert = GetEsiaSignerCert(); var messageBytes = Encoding.UTF8.GetBytes(message); var signatureBytes = Base64UrlEncoder.DecodeBytes(signature); //Переварачиваем байты, так как используется RAW подпись Array.Reverse(signatureBytes, 0, signatureBytes.Length); using (var GostHash = new Gost3411_2012_256CryptoServiceProvider()) { var csp = (Gost3410_2012_256CryptoServiceProvider) signerCert.PublicKey.Key; //Используем публичный ключ сертификата для проверки return csp.VerifyData(messageBytes, GostHash, signatureBytes); } }
Код получения сертификата ЕСИА
X509Certificate2 GetEsiaSignerCert() { var store = new X509Store(StoreName.AddressBook, StoreLocation.CurrentUser); store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); var certificates = store.Certificates.Find(X509FindType.FindBySerialNumber, EsiaCertSerialNumber, false); var certificate = certificates[0]; return certificate; }
Здесь используем введённые ранее константы. И Получаем сертификат из доверенных сертификатов.
Отправка токена на проверку
public async Task<bool> ValidatingAccessToken(EsiaAuthToken token) { if (token.Payload.ExpireDate <= DateTime.Now || token.Payload.BeginDate >= DateTime.Now || token.Payload.CreateDate >= DateTime.Now || token.Payload.ExpireDate <= token.Payload.BeginDate || token.Payload.CreateDate > token.Payload.BeginDate || token.Payload.CreateDate > token.Payload.ExpireDate || token.Payload.Iss != esiaSettings.Value.ISS || token.Payload.ClientId != esiaSettings.Value.ClientId) { return false; } var client = new HttpClient(); var requestParams = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("signature", token.Signature), new KeyValuePair<string, string>("message", token.Message) }; var response = await client.PostAsync($"{cryptoProSettings.Value.BaseUrl}/Verify", new FormUrlEncodedContent(requestParams)); response.EnsureSuccessStatusCode(); var resultResponse = await response.Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject<bool>(resultResponse); return result; }
Этот код используем в нашем основном сервисе.
Проверяем поля токена на актуальность, чтобы его не могли подделать. А потом уже проверяем подпись токена, как указано в методических указаниях.
Получение данных пользователя из ЕСИА
Имея токен мы может отправить запрос на получение данных о пользователе указанных в scope токена. Пример кода, где мы получаем данные пользователя. Здесь esiaUserId содержится в самом токене, это уникальный идентификатор пользователя ЕСИА. Наш токен указываем в заголовке авторизации.
public async Task<EsiaUser> ExecuteAsync(string esiaUserId, string accessToken) { using (var client = new HttpClient()) { client.DefaultRequestHeaders.Clear(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var response = await client.GetStringAsync($"{esiaSettings.Value.EsiaRestUrl}/prns/{esiaUserId}"); var user = JsonConvert.DeserializeObject<EsiaUser>(response); user.Id = user.Id ?? esiaUserId; return user; } }
Код класса EsiaUser
public class EsiaUser { /// <summary> /// Идентификатор /// </summary> [JsonProperty("oid")] public string Id { get; set; } /// <summary> /// Фамилия /// </summary> [JsonProperty("firstName")] public string FirstName { get; set; } /// <summary> /// Имя /// </summary> [JsonProperty("lastName")] public string LastName { get; set; } /// <summary> /// Отчество /// </summary> [JsonProperty("middleName")] public string MiddleName { get; set; } /// <summary> /// Дата рождения /// </summary> [JsonProperty("birthdate")] public string Birthdate { get; set; } /// <summary> /// Пол /// </summary> [JsonProperty("gender")] public string Gender { get; set; } /// <summary> /// Подтвержден ли пользователь /// </summary> [JsonProperty("trusted")] public bool Trusted { get; set; } }
Заключение
Наконец мы закончили интеграцию с ЕСИА. Это был длинный путь полный странных вещей. Неясных решений и множество потраченного времени. Надеюсь этой статьёй я помог Вам реализовать задачу интеграции гораздо быстрее и легче. Спасибо за потраченное время.
ссылка на оригинал статьи https://habr.com/ru/post/708774/
Добавить комментарий