Integration with ESIA for .Net: easier than it sounds

Foreword


Once in a distant, distant galaxy ... it took us to authenticate users using an ESIA account on GosUslugi. Because we live in the .Net galaxy, the first thing that was studied was the whole googol for a ready-made spaceship so as not to crutch everything by ourselves, but searches did not lead to anything worthwhile. Therefore, it was decided to study the topic and implement the same spaceship on its own.





Introduction


, , , — , . , .


... , . , :



3 , 2 – , . 2 : SAML OpenID Connect. ,

01.01.2018 . SAML 2.0 ( ). OAuth 2.0 / OpenID Connect ( ).


. , :


  1. - - « »
  2. -
  3. -
  4. .



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}

where RedirectUri is the address to which the response from the ESIA will be directed, and ClientSecret is the result of the GetClientSecret function. Other parameters are described earlier.


Thus, we get the URL, redirect the user there. The user enters a login password, confirms access to his data for your system. Further, the ESIA sends a response to your system at the address RedirectUri, which contains the authorization code. We will need this code for further inquiries in ESIA.



Obtaining an access token


To get any data in ESIA, we need to get an access token. To do this, we form a POST request in ESIA (for a test environment, the base url is: https://esia-portal1.test.gosuslugi.ru/aas/oauth2/te- EsiaTokenUrl). The main fields of the request here are formed in a similar way, in the code you get something like the following:

/// <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;


Perhaps this is enough for the basic scenario of interaction with ESIA. In general, if you know the features of the implementation, software connection of the system to ESIA will not take more than 1 day. If you have any questions, welcome to comment. Thank you for reading my post to the end, I hope it will be useful.


All Articles