Как стать автором
Обновить

Интеграция с ЕСИА для .Net: проще, чем кажется

Время на прочтение10 мин
Количество просмотров26K

Предисловие


Однажды в далекой-далекой галактике… потребовалось нам реализовать аутентификацию пользователей с помощью учетной записи ЕСИА на ГосУслугах. Т.к. обитаем мы в галактике .Net, первым делом был изучен весь гугол на предмет готового космолета дабы не костылить все самим, но поиски ни к чему путному не привели. Поэтому решено было изучить тему и реализовать-таки космолет своими силами.





Введение


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


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


  1. Регистрация ИС в регистре информационных систем ЕСИА
  2. Регистрация ИС в тестовой среде
  3. Выполнение доработки системы для взаимодействия с ЕСИА

В данной статье будет описан только 3 пункт, предыдущие 2 – бюрократия, оставим ее за рамками Хабра. В методичке предлагают реализовать интеграцию 2 способами: SAML или OpenID Connect. Говорят,

с 01.01.2018 г. взаимодействие по протоколу SAML 2.0 больше не будет разрешено (только для действующих систем). Для подключения к ЕСИА необходимо будет использовать протокол OAuth 2.0 / OpenID Connect (сейчас доступны оба варианта).


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


  1. Пользователь нажимает на веб-странице системы-клиента кнопку «Войти через ЕСИА»
  2. Система-клиент отправляет в ЕСИА запрос на аутентификацию
  3. ЕСИА осуществляет аутентификацию пользователя посредством логина-пароля
  4. После успешной аутентификации ЕСИА возвращает ответ системе с кодом аутентификации.
  5. Используя код аутентификации система получает доступ к данным пользователя


Базовая информация


Мы реализовали сервис интеграции с ЕСИА под Windows, используя КриптоПро CSP. В теории скорее всего можно это все аккуратно упаковать в docker и положить в Linux-образные системы, оставим это на откуп читателю. Для нас же актуальным стеком был следующий:


  • .Net Framework 4.8
  • КриптоПро CSP, КриптоПро .Net
  • Сертификат с закрытым ключом, полученный при регистрации ИС в реестре ИС ЕСИА (на пункте 1 из Введения)

Каждый запрос в ЕСИА по соображениям безопасности дополняется полем client_secret, которое формируется как открепленная подпись 4 полей запроса в формате UTF-8:


  • Scope (Скоуп запроса, перечень данных, которые нужно получить из ЕСИА). Например, «fullname gender email mobile usr_org»
  • Timestamp (Текущие дата и время в формате «yyyy.MM.dd HH:mm:ss +0000»)
  • ClientId (Идентификатор ИС, который выдается при регистрации системы в ЕСИА)
  • State (Идентификатор текущего запроса, каждый раз генерируется как Guid.NewGuid().ToString(«D»))

private string GetClientSecret(
	X509Certificate2 certificate, 
	string scope, 
	string timestamp, 
	string clientId, 
	string state)
{
	var signMessage = Encoding.UTF8.GetBytes($"{scope}{timestamp}{clientId}{state}");

	byte[] encodedSignature = SignatureProvider.Sign(signMessage, certificate);
 
	return Base64UrlEncoder.Encode(encodedSignature);
}

Тут SignatureProvider – класс для реализации работы с сертификатами, он довольно просто реализуется. Для подписи использовался алгоритм ГОСТ – импортозамещение и все такое.



Перенаправление в ЕСИА и аутентификация


Начнем пошаговую реализацию с пунктов 1-4: нам нужно перенаправить пользователя на сервис аутентификации ЕСИА (EsiaAuthUrl). В зависимости от среды (тестовая или продуктив) различается базовый адрес url – для тестовой среды это https://esia-portal1.test.gosuslugi.ru/aas/oauth2/ac. Полный адрес получается таким:


{EsiaAuthUrl}?client_id={ClientId}&scope={Scope}&response_type=code&state={State}& timestamp={Timestamp}&access_type=online&redirect_uri={RedirectUri}&client_secret={ClientSecret}

где RedirectUri – адрес, на который будет направлен ответ от ЕСИА, а ClientSecret – результат выполнения функции GetClientSecret. Остальные параметры описаны ранее.


Таким образом, получаем URL, перенаправляем туда пользователя. Пользователь вводит логин-пароль, подтверждает доступ к своим данным для вашей системы. Далее ЕСИА отправляет ответ вашей системе по адресу RedirectUri, в котором содержится код авторизации. Этот код нам понадобится для дальнейших запросов в ЕСИА.



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


Для получение каких-либо данных в ЕСИА нам нужно получить токен доступа. Для этого формируем POST запрос в ЕСИА (для тестовой среды базовый url такой: https://esia-portal1.test.gosuslugi.ru/aas/oauth2/te — EsiaTokenUrl). Основные поля запроса тут формируются аналогичным образом, в коде получается примерно следующее:

/// <summary>
/// Получить токен доступа
/// </summary>
/// <param name="code">Код доступа для получения кода доступа</param>
/// <param name="callbackUrl">Коллбек адрес для редиректа после автризации</param>
/// <param name="certificate">Сертификат для подписи запросов</param>
/// <returns>Токен доступа</returns>
public async Task<EsiaAuthToken> GetAccessToken(
	string code,
	string callbackUrl = null,
	X509Certificate2 certificate = null)
{
	var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");
	var state = Guid.NewGuid().ToString("D");

	// Create signature in PKCS#7 detached signature UTF-8
	var clientSecret = GetClientSecret(
		certificate,
		Configuration.Scope,	// Скоуп запроса
		timestamp,
		Configuration.ClientId,	// Идентификатор ИС
		state);

	var requestParams = new List<KeyValuePair<string, string>>
	{
		new KeyValuePair<string, string>("client_id", Configuration.ClientId),
		new KeyValuePair<string, string>("code", code),
		new KeyValuePair<string, string>("grant_type", "authorization_code"),
		new KeyValuePair<string, string>("state", state),
		new KeyValuePair<string, string>("scope", Configuration.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", callbackUrl)
	};
	using (var client = new HttpClient())
	using (var response = await client.PostAsync(Configuration.EsiaTokenUrl, new FormUrlEncodedContent(requestParams)))
	{
		response.EnsureSuccessStatusCode();
		var tokenResponse = await response.Content.ReadAsStringAsync();

		var token = JsonConvert.DeserializeObject<EsiaAuthToken>(tokenResponse);

		Argument.NotNull(token?.AccessToken, "Не найден токен доступа");
		Argument.Require(state == token.State, "Идентификатор запроса некорректный");

		return token;
	}
}

Некоторые статичные параметры запроса получаем из файла конфигурации (поле Configuration). Как мы можем видеть, поле code запроса заполняется значением кода аутентификации, полученным ранее. Для десериализации ответа используется следующие классы:


/// <summary>
/// Ответ от ЕСИА с токеном доступа
/// </summary>
public class EsiaAuthToken
{
	/// <summary>
	/// Токен доступа
	/// </summary>
	[JsonProperty("access_token")]
	public string AccessToken { get; set; }

	/// <summary>
	/// Идентификатор запроса
	/// </summary>
	public string State { get; set; }

	/// <summary>
	/// Хранилище данных в токене
	/// </summary>
	public EsiaAuthTokenPayload Payload
	{
		get
		{
			if (string.IsNullOrEmpty(AccessToken))
			{
				return null;
			}

			string[] parts = AccessToken.Split('.');
			if (parts.Length < 2)
			{
				throw new System.Exception($"При расшифровке токена доступа произошла ошибка. Токен: {AccessToken}");
			}

			var payload = Encoding.UTF8.GetString(Base64UrlEncoder.Decode(parts[1]));
			return JsonConvert.DeserializeObject<EsiaAuthTokenPayload>(payload);
		}
	}
}

/// <summary>
/// Данные из токена доступа
/// </summary>
public class EsiaAuthTokenPayload
{
	/// <summary>
	/// Идентификатор токена
	/// </summary>
	[JsonProperty("urn:esia:sid")]
	public string TokenId { get; set; }

	/// <summary>
	/// Идентификатор пользователя
	/// </summary>
	[JsonProperty("urn:esia:sbj_id")]
	public string UserId { get; set; }
}

Получение данных пользователя из ЕСИА


Имея токен доступа и идентификатор пользователя можно получить данные о пользователе из ЕСИА. Рассмотрим пример получения ФИО пользователя и его организации. Для этого формируем GET запрос в REST API ЕСИА, для тестового контура базовый url (EsiaRestUrl) будет таким: https://esia-portal1.test.gosuslugi.ru/rs. Токен доступа передается в заголовке запроса, сам код выглядит следующим образом:


/// <summary>
/// Получить данные пользователя
/// </summary>
/// <param name="userId">Идентификатор пользователя</param>
/// <param name="accessToken">Токен доступа</param>
/// <returns>Данные пользователя</returns>
public async Task<EsiaUser> GetUser(string userId, string accessToken)
{
	using (var client = new HttpClient())
	{
		client.DefaultRequestHeaders.Clear();
		client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

		var response = await client.GetStringAsync($"{Configuration.EsiaRestUrl}/prns/{userId}?embed=(organizations)");
		var user = JsonConvert.DeserializeObject<EsiaUser>(response);
		user.Id = user.Id ?? userId;

		return user;
	}
}

Этот метод принимает на вход идентификатор пользователя в ЕСИА и токен доступа – оба эти параметра мы получили на предыдущем этапе. Заметьте, запрос можно расширить и наряду с ФИО пользователя получить список его организаций. В этом случае мы получим список ссылок на организации пользователя, по которым можно будет получить данные в свою очередь. Таким образом, у нас получилась такая модель пользователя ЕСИА:


/// <summary>
/// Пользователь ЕСИА
/// </summary>
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("trusted")]
	public bool Trusted { get; set; }

	/// <summary>
	/// Ссылки на организации
	/// </summary>
	[JsonProperty("organizations")]
	public EsiaUserOrganizations OrganizationLinks { get; set; }
}

/// <summary>
/// Ссылки на организации
/// </summary>
public class EsiaUserOrganizations
{
	[JsonProperty("elements")]
	public List<string> Links { get; set; }
}

Получение данных организации


Полученный токен доступа для запроса данных по организации мы использовать не можем, т.к. он завязан на определенный scope. Поэтому нужно получить отдельный токен. По сути методы получения токена доступа для организации и информации по ней не сильно отличаются от рассмотренных ранее. Для получения токена доступа мы используем идентификатор организации, код аутентификации, State предыдущего запроса на токен доступа. Новый scope формируется как список scope’ов организации, разделенных через пробел, например:


http://esia.gosuslugi.ru/org_shortname/?org_oid={organizationId} http://esia.gosuslugi.ru/ org_fullname/?org_oid={organizationId}

/// <summary>
/// Получить токен для доступа к организациям пользователя
/// </summary>
/// <param name="organizationId">Идентификатор организации</param>
/// <param name="code">Код доступа</param>
/// <param name="state">Идентификатор запроса</param>
/// <param name="callbackUrl">Коллбек адрес для редиректа после авторизации</param>
/// <param name="certificate">Сертификат для подписи запросов</param>
/// <returns>Токен для доступа к организациям пользователя</returns>
public async Task<EsiaAuthToken> GetOrganizationAccessToken(
	string organizationId,
	string code,
	string state,
	string callbackUrl = null,
	X509Certificate2 certificate = null)
{
	var timestamp = DateTime.UtcNow.ToString("yyyy.MM.dd HH:mm:ss +0000");
	var scope = string.Join(" ", Configuration.OrganizationScope.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
		.Select(orgScope => $"{Configuration.EsiaBaseUrl}/{orgScope}?org_oid={organizationId}"));

	// Create signature in PKCS#7 detached signature UTF-8
	var clientSecret = GetClientSecret(
		certificate,
		scope,
		timestamp,
		Configuration.ClientId,
		state);

	var requestParams = new List<KeyValuePair<string, string>>
	{
		new KeyValuePair<string, string>("client_id", Configuration.ClientId),
		new KeyValuePair<string, string>("code", code),
		new KeyValuePair<string, string>("grant_type", "client_credentials"),
		new KeyValuePair<string, string>("state", state),
		new KeyValuePair<string, string>("scope", 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", callbackUrl)
	};
	using (var client = new HttpClient())
	using (var response = await client.PostAsync(Configuration.EsiaTokenUrl, new FormUrlEncodedContent(requestParams)))
	{
		response.EnsureSuccessStatusCode();
		var tokenResponse = await response.Content.ReadAsStringAsync();

		var token = JsonConvert.DeserializeObject<EsiaAuthToken>(tokenResponse);

		Argument.NotNull(token?.AccessToken, "Не найден токен доступа");
		Argument.Require(state == token.State, "Идентификатор запроса некорректный");

		return token;
	}
}

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


/// <summary>
/// Получить данные организации
/// </summary>
/// <param name="organizationLink">Ссылка на организацию</param>
/// <param name="accessToken">Токен доступа</param>
/// <returns>Данные организации</returns>
public async Task<EsiaOrganization> GetOrganization(string organizationLink, string accessToken)
{
	using (var client = new HttpClient())
	{
		client.DefaultRequestHeaders.Clear();
		client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

		var response = await client.GetStringAsync(organizationLink);
		var organization = JsonConvert.DeserializeObject<EsiaOrganization>(response);
		return organization;
	}
}

Полностью же код получения пользователя и его организации из ЕСИА выглядит так:


// Получим токен доступа по коду доступа
var accessToken = await IntegrationService.GetAccessToken(request.Code, request.CallbackUrl);

// Получим информацию о пользователе из ЕСИА
var user = await IntegrationService.GetUser(accessToken.Payload.UserId, accessToken.AccessToken);

// Если у пользователя есть организации - полезем в ЕСИА за ними
if (user.OrganizationLinks?.Links?.Any() == true)
{
	// Новый токен доступа - чисто для организаций
	var link = user.OrganizationLinks.Links.First();
	var organizationId = link.Split('/').Last();
	var organizationAccessToken = await IntegrationService.GetOrganizationAccessToken(organizationId, request.Code, accessToken.State, request.CallbackUrl);

	user.Organization = await IntegrationService.GetOrganization(link, organizationAccessToken.AccessToken);
}

return user;

Заключение


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

Теги:
Хабы:
Всего голосов 15: ↑15 и ↓0+15
Комментарии2

Публикации

Истории

Работа

Ближайшие события