Всем привет. Меня зовут Динис, я старший разработчик в БФТ-Холдинге.
В данной статье приведен план по созданию сервиса авторизации через систему ЕСИА. Тема будет интересна тем, кто внедряет такой сервис для своего приложения. В статье я собрал ключевые выдержки из документации с частями кода, а весь код вы можете найти в конце статьи по ссылке на GitHub.
Что такое ЕСИА?
ЕСИА — единая система идентификации и аутентификации. В ней формируются, учитываются и хранятся сведения об участниках системы — физических и юридических лицах, которые прошли регистрацию с созданием учётной записи. Подтверждённая учётная запись в ЕСИА — своего рода электронный паспорт, с помощью которого можно получать доступ к разным сайтам, порталам и системам, не используя дополнительные средства регистрации и авторизации, например логин и пароль.
У сервиса ЕСИА прекрасная документация, поэтому большая часть данной статьи содержит ее выдержки – они выделены курсивом. Полная версия документации доступна по ссылке: https://digital.gov.ru/ru/documents/6182/
Теперь – к самому интересному.
1. Создание Java-проекта
Для начала добавим эти зависимости:
В качестве криптопровайдера будем использовать российский криптопровайдер Vipnet, сертифицированный ФСБ России как средство криптографической защиты информации и электронной подписи.
Скачаем с сайта (https://infotecs.ru/products/vipnet-jcrypto-sdk/) библиотеки крипторовайдера. Добавим их в директорию jcr/lib в корень проекта.
Добавим зависимости build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.15'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'ru.habr'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '1.8'
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "2021.0.8")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter'
implementation 'org.springframework.cloud:spring-cloud-starter-config'
implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
compileOnly 'org.projectlombok:lombok:1.18.22'
annotationProcessor 'org.projectlombok:lombok:1.18.22'
// Для работы с шифрованием по ГОСТ.
implementation files('jcr/lib/jcrypto-jca-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-ocsp-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-pkcs11-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-pkcs7-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-smime-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-ssl-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-widgets-fx-2.8.6-R42.jar')
implementation files('jcr/lib/jcrypto-xmldsig-2.8.6-R42.jar')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
Заполним наш application.yml:
server:
port: 8081
spring:
application:
name: esia
habr:
esia:
scope: fullname birthdate gender snils id_doc email contacts kid_fullname kid_birthdate kid_gender kid_snils kid_inn kid_birth_cert_doc kid_medical_doc
clientId: CLIENT-ID
ketStorageDirectory: /home/esia/eiis_keys
ketStoragePassword: password
ketFile: pkiClient-container
host: esia-portal1.test.gosuslugi.ru
baseUrl: https://esia-portal1.test.gosuslugi.ru/
authCodeUlr: https://esia-portal1.test.gosuslugi.ru/aas/oauth2/ac
redirectUrl: http://127.0.01:8081
Настроим конфигурацию веб-клиента:
@Configuration
public class WebClientConfiguration {
@Value("${habr.esia.baseUrl}")
private String baseUrl;
public static final int TIMEOUT = 1000;
@Bean
public WebClient webClientWithTimeout() {
final TcpClient tcpClient = TcpClient
.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TIMEOUT)
.doOnConnected(connection -> {
connection.addHandlerLast(new ReadTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS));
connection.addHandlerLast(new WriteTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS));
});
return WebClient.builder()
.baseUrl(baseUrl)
.clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)))
.build();
}
}
Так как мы будем работать с jwt токенами, создадим класс JwtUtil:
@Service
@Slf4j
public class JwtUtil {
private ObjectMapper mapper;
@PostConstruct
public void init() {
this.mapper = new ObjectMapper();
}
public Map<String, Object> getTokenData(String token) {
token = this.withoutBearerToken(token);
String[] parts = token.split("\\.");
String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
Map<String, Object> jsonMap = new HashMap<String, Object>();
try {
// convert JSON string to Map
jsonMap = this.mapper.readValue(payload,
new TypeReference<Map<String, Object>>() {
});
} catch (Exception ex) {
log.error("Ошибка парсинга токена: {}", ex.getMessage());
throw new RuntimeException(ex);
}
return jsonMap;
}
public String getUserOid(String token) {
return String.valueOf(getTokenData(token).get("urn:esia:sbj_id"));
}
public String withoutBearerToken(String token) {
if (token.startsWith("Bearer ")) {
return token.substring(7);
}
return token;
}
public String withBearerToken(String token) {
if (!token.startsWith("Bearer ")) {
return String.format("Bearer %s", token);
}
return token;
}
}
2. Реализация сервиса авторизации
Общие принципы
Данная модель контроля доступа используется в случаях, когда система-клиент при доступе к ресурсу должна получить разрешение на это действие со стороны владельца ресурса.
В общем виде схема взаимодействия выглядит следующим образом:
Система-клиент запрашивает у владельца ресурса разрешение на доступ к соответствующим ресурсам. Обычно этот запрос осуществляется не напрямую к владельцу ресурса, а через сервис авторизации (который, в свою очередь, запрашивает разрешение у владельца ресурса), поскольку сам владелец ресурса не может выдать ни маркер доступа, ни авторизационный код.
Система-клиент получает разрешение на доступ (authorization grant) в виде авторизационного кода.
Система-клиент запрашивает маркер доступа, предъявив авторизационный код сервису авторизации.
Сервис авторизации аутентифицирует систему-клиента, проверяет авторизационный код и выдает маркер доступа и маркер обновления.
Система-клиент запрашивает у поставщика защищенный ресурс, предъявляя маркер доступа.
Поставщик ресурса проверяет маркер доступа. Если он валиден, то разрешает доступ к защищенному ресурсу.
Система-клиент вновь запрашивает с помощью выданного ранее маркера доступ к защищенному ресурсу.
Поставщик ресурса проверяет маркер, обнаруживает, что срок его действия истек, возвращает сообщение об ошибке.
Система-клиент обращается к сервису авторизации за получением нового маркера доступа, предъявляя маркер обновления.
Сервис авторизации проверяет валидность маркера обновления и возвращает два новых маркера: доступы и обновления.
После того, как система-клиент получила маркер доступа, она может неоднократно обращаться за получением защищенного ресурса до конца срока действия этого маркера. Когда это произойдет, системе-клиенту потребуется получить новый маркер доступа.
Метод получения авторизационного кода по определенному URL
Чтобы получить авторизационный код, система-клиент должна получить разрешение на доступ к защищенному ресурсу со стороны его владельца. Если владельцем является пользователь ЕСИА, система-клиент должна направить пользователя на страницу предоставления прав доступа в ЕСИА (пользователь должен быть предварительно аутентифицирован в системе, или она попросит его пройти идентификацию и аутентификацию).
public String getUrl(ServerRequest request) throws EsiaException, UnsupportedEncodingException {
ClientSecretResponse clientSecretResponse = this.getClientSecret();
String type = request.pathVariable("redirect");
String timestampUrlEncoded = getTimestampUrlEncoded(clientSecretResponse);
String redirectUrlEncoded = getRedirectUrlEncoded(String.format("http://%s:%s/%s",
request.uri().getHost(),
request.uri().getPort(),
type));
UriComponentsBuilder accessTokenRequestBuilder = UriComponentsBuilder.fromHttpUrl(this.authCodeUlr)
.queryParam("client_id", URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString()))
.queryParam("response_type", URLEncoder.encode("code", StandardCharsets.UTF_8.toString()))
.queryParam("access_type", URLEncoder.encode("offline", StandardCharsets.UTF_8.toString()))
.queryParam("scope", URLEncoder.encode(scope, StandardCharsets.UTF_8.toString()))
.queryParam("state", URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString()))
.queryParam("client_secret", URLEncoder.encode(clientSecretResponse.getClient_secret(), StandardCharsets.UTF_8.toString()));
String url = accessTokenRequestBuilder.toUriString();
url += "×tamp=" + timestampUrlEncoded;
url += "&redirect_uri=" + redirectUrlEncoded;
return url;
}
Эта ссылка содержит:
<client_id> – идентификатор системы-клиента (мнемоника системы в ЕСИА, указанная прописными буквами);
<client_secret> – подпись запроса в формате PKCS#7 detached signature
в кодировке UTF-8 от значений четырех параметров HTTP-запроса: scope, timestamp, clientId, state (без разделителей). <client_secret> должен быть закодирован в формате base64 url safe. Используемый для проверки подписи сертификат должен быть предварительно зарегистрирован в ЕСИА и привязан к УЗ системы-клиента в ЕСИА. ЕСИА использует сертификаты в формате X.509 и взаимодействует с алгоритмами формирования электронной подписи ГОСТ Р 34.10-2012 и криптографического хэширования ГОСТ Р 34.11-2012;<redirect_uri> – ссылка, по которой должен быть направлен пользователь
после того, как даст разрешение на доступ к ресурсу;<scope> – область доступа, т.е. запрашиваемые права; например, если система-клиент запрашивает доступ к сведениям о сотрудниках организации,
то область доступа (scope) должна иметь значение http://esia.gosuslugi.ru/org_emps (с необходимыми параметрами);
если запрашивается область доступа (scope) id_doc (данные о пользователе), то не нужно в качестве параметра указывать oid этого пользователя;<response_type> – это тип ответа, который ожидается от ЕСИА, имеет значение code, если система-клиент должна получить авторизационный код;
<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса (необходимо для защиты от перехвата), генерируется по стандарту UUID;
<timestamp> – время запроса авторизационного кода в формате yyyy.MM.dd HH:mm:ss Z (например, 2013.01.25 14:36:11 +0400), необходимое для фиксации начала временного промежутка, в течение которого будет валиден запрос
с данным идентификатором (<state>);<access_type> – принимает значение «offline», если требуется иметь доступ
к ресурсам и тогда, когда владелец не может быть вызван (в этом случае выпускается маркер обновления); значение «online» – доступ требуется только при наличии владельца.
Когда авторизационный код получен, система-клиент может сформировать запрос методом POST на https-адрес ЕСИА для получения маркера доступа. Один авторизационный код можно обменять на один маркер доступа.
public Mono<LinkedHashMap> openEsiaSession(String code, String state, ServerRequest request) throws EsiaException, IOException {
ClientSecretResponse clientSecretResponse = this.getClientSecret();
String timestampUrlEncoded = getTimestampUrlEncoded(clientSecretResponse);
String redirectUrlEncoded = getRedirectUrlEncoded(String.format("http://%s:%s",
request.uri().getHost(),
request.uri().getPort()));
StringBuilder formData = new StringBuilder("&");
formData.append(URLEncoder.encode("grant_type", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode("authorization_code", StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("client_id", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("code", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(code, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("state", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("token_type", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode("Bearer", StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("scope", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(scope, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("refresh_token", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("client_secret", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getClient_secret(), StandardCharsets.UTF_8.toString())).append("&");
formData.append("timestamp").append("=").append(timestampUrlEncoded).append("&");
formData.append(URLEncoder.encode("redirect_uri", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(redirectUrlEncoded, StandardCharsets.UTF_8.toString()));
return webClient
.post()
.uri(uriBuilder ->
uriBuilder.host(esiaHost)
.path("/aas/oauth2/te")
.build()
)
.header("Content-Type", "application/x-www-form-urlencoded")
.bodyValue(formData.toString())
.retrieve()
.bodyToMono(LinkedHashMap.class)
.timeout(Duration.ofMillis(30000))
.onErrorResume(e -> {
LinkedHashMap<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("error", e.getMessage());
return Mono.just(errorMap);
})
.doOnError(error -> {
log.error("An error has occurred {}", error.getMessage());
throw new RuntimeException();
});
}
В тело запроса должны быть включены следующие сведения:
<client_id> – идентификатор системы-клиента (мнемоника системы в ЕСИА, указанная прописными буквами);
<code> – значение авторизационного кода, который был ранее получен
от ЕСИА, его необходимо обменять на маркер доступа;<grant_type> – принимает значение «authorization_code»,
если авторизационный код обменивается на маркер доступа;<client_secret> – подпись запроса в формате PKCS#7 detached signature
в кодировке UTF-8 от значений четырех параметров HTTP-запроса: scope, timestamp, clientId, state (без разделителей). <client_secret> должен быть закодирован в формате base64 url safe. Используемый для проверки подписи сертификат должен быть предварительно зарегистрирован в ЕСИА и привязан к УЗ системы-клиента в ЕСИА. ЕСИА использует сертификаты в формате X.509 и взаимодействует с алгоритмами формирования электронной подписи ГОСТ Р 34.10-2012
и криптографического хэширования ГОСТ Р 34.11-2012;<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса (необходимо для защиты от перехвата), генерируется по стандарту UUID; этот набор символов должен отличаться от того, который использовался при получении авторизационного кода;
<redirect_uri> – ссылка, по которой должен быть направлен пользователь
после того, как даст разрешение на доступ (то же самое значение, которое было указано в запросе на получение авторизационного кода);<scope> – область доступа, т.е. запрашиваемые права (то же самое значение, которое было указано в запросе на получение авторизационного кода);
<scope_org> – область доступа, т.е. запрашиваемые права для юридических лиц (то же самое значение, которое было указано в запросе на получение авторизационного кода);
<timestamp> – время запроса маркера в формате yyyy.MM.dd HH:mm:ss Z (например, 2013.01.25 14:36:11 +0400), необходимое для фиксации начала временного промежутка, в течение которого будет валиден запрос с данным идентификатором (<state>);
-
<token_type> – тип запрашиваемого маркера, в настоящее время ЕСИА поддерживает только значение «Bearer». Параметр необязательный.
Если запрос успешно прошел проверку, то ЕСИА возвращает ответ в формате JSON:
<access_token> – маркер доступа для данного ресурса;
<expires_in> – время, в течение которого истекает срок действия маркера
(в секундах);<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса, генерируется по стандарту UUID (совпадает
с идентификатором запроса);-
<token_type> – тип предоставленного маркера, в настоящее время ЕСИА поддерживает только значение «Bearer»;
<refresh_token> – маркер обновления для данного ресурса.
Генерируем client_secret
public ClientSecretResponse getClientSecret() throws EsiaException {
try {
ZonedDateTime now = ZonedDateTime.now();
String timestamp = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss xx").format(now);
String state = UUID.randomUUID().toString();
String msg = String.format("%s%s%s%s", scope, timestamp, clientId, state);
byte[] messageAsByte = msg.getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream clientSecretOS = new ByteArrayOutputStream();
try (
CMSSignedDataOutputStream signedStream = new CMSSignedDataOutputStream(clientSecretOS)) {
signedStream.addCertificates(certificate);
signedStream.addSigner(privateKey, certificate);
signedStream.write(messageAsByte, 0, messageAsByte.length);
}
byte[] utf = clientSecretOS.toByteArray();
String clientSecret = new String(Base64.getEncoder().encode(utf));
String clientSecretUrlEncoded = clientSecret.replace("+", "-")
.replace("/", "_")
.replace("=", "");
log.debug("Generated new clientSecret:" + clientSecretUrlEncoded);
return new ClientSecretResponse(timestamp, state, scope, clientSecretUrlEncoded);
} catch (Exception error) {
throw new EsiaException(error);
}
}
Если в ходе авторизации не возникло ошибок, то ЕСИА осуществляет перенаправление пользователя по ссылке, указанной в redirect_uri, а также возвращает два обязательных параметра:
<code> – значение авторизационного кода;
<state> – значение параметра state, который был получен в запросе
на авторизацию; система-клиент должна провести сравнение отправленного
и полученного параметра state.
В случае ошибки сервис авторизации вернет в параметре error код ошибки (например, «access_denied») и перенаправит пользователя по адресу, указанному
в redirect_uri.
Когда авторизационный код получен, система-клиент может сформировать запрос методом POST на https-адрес ЕСИА для получения маркера доступа. Один авторизационный код можно обменять на один маркер доступа. В тело запроса должны быть включены следующие сведения:
<client_id> – идентификатор системы-клиента (мнемоника системы в ЕСИА указанная прописными буквами);
<code> – значение авторизационного кода, который был ранее получен
от ЕСИА и который необходимо обменять на маркер доступа;<grant_type> – принимает значение «authorization_code»,
если авторизационный код обменивается на маркер доступа;<client_secret> – подпись запроса в формате PKCS#7 detached signature
в кодировке UTF-8 от значений четырех параметров HTTP-запроса: scope, timestamp, clientId, state (без разделителей). <client_secret> должен быть закодирован в формате base64 url safe. Используемый для проверки подписи сертификат должен быть предварительно зарегистрирован в ЕСИА и привязан к УЗ системы-клиента в ЕСИА. ЕСИА использует сертификаты в формате X.509 и взаимодействует с алгоритмами формирования электронной подписи ГОСТ Р 34.10-2012
и криптографического хэширования ГОСТ Р 34.11-2012;<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса (необходимо для защиты от перехвата), генерируется по стандарту UUID; этот набор символов должен отличаться от того, который использовался при получении авторизационного кода;
<redirect_uri> – ссылка, по которой должен быть направлен пользователь
после того, как даст разрешение на доступ (то же самое значение, которое было указано в запросе на получение авторизационного кода);<scope> – область доступа, т.е. запрашиваемые права (то же самое значение, которое было указано в запросе на получение авторизационного кода);
<scope_org> – область доступа, т.е. запрашиваемые права для юридических лиц (то же самое значение, которое было указано в запросе на получение авторизационного кода);
<timestamp> – время запроса маркера в формате yyyy.MM.dd HH:mm:ss Z (например, 2013.01.25 14:36:11 +0400), необходимое для фиксации начала временного промежутка, в течение которого будет валиден запрос с данным идентификатором (<state>);
<token_type> – тип запрашиваемого маркера, в настоящее время ЕСИА поддерживает только значение «Bearer». Параметр необязательный.
public Mono<LinkedHashMap> openEsiaSession(String code, String state, ServerRequest request) throws EsiaException, IOException {
ClientSecretResponse clientSecretResponse = this.getClientSecret();
String timestampUrlEncoded = getTimestampUrlEncoded(clientSecretResponse);
String redirectUrlEncoded = getRedirectUrlEncoded(String.format("http://%s:%s",
request.uri().getHost(),
request.uri().getPort()));
StringBuilder formData = new StringBuilder("&");
formData.append(URLEncoder.encode("grant_type", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode("authorization_code", StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("client_id", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("code", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(code, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("state", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("token_type", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode("Bearer", StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("scope", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(scope, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("refresh_token", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("client_secret", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getClient_secret(), StandardCharsets.UTF_8.toString())).append("&");
formData.append("timestamp").append("=").append(timestampUrlEncoded).append("&");
formData.append(URLEncoder.encode("redirect_uri", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(redirectUrlEncoded, StandardCharsets.UTF_8.toString()));
return webClient
.post()
.uri(uriBuilder ->
uriBuilder.host(esiaHost)
.path("/aas/oauth2/te")
.build()
)
.header("Content-Type", "application/x-www-form-urlencoded")
.bodyValue(formData.toString())
.retrieve()
.bodyToMono(LinkedHashMap.class)
.timeout(Duration.ofMillis(30000))
.onErrorResume(e -> {
LinkedHashMap<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("error", e.getMessage());
return Mono.just(errorMap);
})
.doOnError(error -> {
log.error("An error has occurred {}", error.getMessage());
throw new RuntimeException();
});
}
Если запрос успешно прошел проверку, то ЕСИА возвращает ответ в формате JSON:
<access_token> – маркер доступа для данного ресурса;
<expires_in> – время, в течение которого истекает срок действия маркера
(в секундах);<state> – набор случайных символов, имеющий вид 128-битного идентификатора запроса, генерируется по стандарту UUID (совпадает
с идентификатором запроса);<token_type> – тип предоставленного маркера, в настоящее время ЕСИА поддерживает только значение «Bearer»;
<refresh_token> – маркер обновления для данного ресурса.
При использовании маркера доступа системам-клиентам рекомендуется сначала проверять, не истек ли срок его действия. Если маркер просрочен, то для успешного доступа к защищенному ресурсу потребуется предварительно получить новый маркер доступа с использованием маркера обновления. Для этого системе-клиенту следует сформировать запрос методом POST в адрес ЕСИА, имеющий структуру, аналогичную первичному запросу на получение маркера. Особенности значений параметров запроса:
<refresh_token> – значение имеющегося у системы-клиента маркера обновления, который следует обменять на новый маркер доступа (указывается вместо <code>)
<grant_type> – должно иметь значение «refresh_token», поскольку маркер обновления обменивается на маркер доступа
public Mono<LinkedHashMap<String, Object>> updateEsiaSession(String refreshToken, ParameterizedTypeReference<LinkedHashMap<String, Object>> typeReference, ServerRequest request) throws EsiaException, IOException {
ClientSecretResponse clientSecretResponse = this.getClientSecret();
String timestampUrlEncoded = getTimestampUrlEncoded(clientSecretResponse);
String redirectUrlEncoded = getRedirectUrlEncoded(String.format("http://%s:%s",
request.uri().getHost(),
request.uri().getPort()));
StringBuilder formData = new StringBuilder("&");
formData.append(URLEncoder.encode("client_id", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("client_secret", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getClient_secret(), StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("refresh_token", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("scope", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(scope, StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("grant_type", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode("refresh_token", StandardCharsets.UTF_8.toString())).append("&");
formData.append(URLEncoder.encode("state", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(clientSecretResponse.getState(), StandardCharsets.UTF_8.toString())).append("&");
formData.append("timestamp").append("=").append(timestampUrlEncoded).append("&");
formData.append(URLEncoder.encode("redirect_uri", StandardCharsets.UTF_8.toString())).append("=").append(URLEncoder.encode(redirectUrlEncoded, StandardCharsets.UTF_8.toString()));
return webClient
.post()
.uri(uriBuilder ->
uriBuilder.host(esiaHost)
.path("/aas/oauth2/te")
.build()
)
.header("Content-Type", "application/x-www-form-urlencoded")
.bodyValue(formData.toString())
.retrieve()
.bodyToMono(typeReference)
.timeout(Duration.ofMillis(30000))
.onErrorResume(e -> {
LinkedHashMap<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("error", e.getMessage());
return Mono.just(errorMap);
})
.doOnError(error -> {
log.error("An error has occurred {}", error.getMessage());
throw new RuntimeException();
});
}
Ответ на этот запрос дается в формате JSON и имеет ту же структуру, как и при первичном предоставлении маркера доступа. В этом ответе содержится новый маркер обновления, который система-клиент должна хранить вместо уже использованного маркера обновления.
Весь код данной статьи можно найти по ссылке на GitHub: https://github.com/saetdin/esia/tree/main
Комментарии (12)
dprotopopov
02.11.2023 12:58+1и взаимодействует с алгоритмами формирования электронной подписи ГОСТ Р 34.10-2012 и криптографического хэширования ГОСТ Р 34.11-2012;
а можно поподробнее - какое приложение-провайдер этой реализации использовалось
например, .net у микрософта нет поддержки российских стандартов от слова совсем - соответственно, есть вариант использования криптопро - они пропатчили corefx для вызова своей аппликухи и выложили на github - но явно встраивать патченый net в бизнес-приложение не резон, но можно вынести в микросервис, заодно там же поднять и аппликуху от криптопро
Dinis_S Автор
02.11.2023 12:58в данном случае использовался провайдер vipnet, библиотека ViPNet JCrypto SDK, так же можно легко мигрировать на CryptoPro JCP.
antonvn
02.11.2023 12:58А почему не BouncyCastle? Там тоже поддержка ГОСТа давно есть.
И вдогонку, все это хорошо работает, когда сертификаты и ключи в файликах. А если они на токене, и ключ неизвлекаем?
aleksandy
02.11.2023 12:58+1URLEncoder.encode("client_id", StandardCharsets.UTF_8.toString())
Dinis_S Автор
02.11.2023 12:58бывают случаи, когда не всегда корректно парсится URL в виде строки, поэтому тут строки явно кдируются в URL
aleksandy
02.11.2023 12:58Ъ по ссылкам не ходят? Документацию не читают?
Hidden text
The alphanumeric characters "a" through "z", "A" through "Z" and "0" through "9" remain the same.
olegchir
02.11.2023 12:58+1Как вам вообще работа с ЕСИА? Нравится? Всё удобно и хорошо, или какие-то части выглядят сложными или неочевидными?
Dinis_S Автор
02.11.2023 12:58+1в целом работать с ЕСИА удобно, очень хорошая документация, открытое понятное API, бывали перебои(1-2 раза в год), но в течение 1-2 часа все исправлют.
olegchir
02.11.2023 12:58Представим, что у меня есть какой-то условный "Тиндер для собак", и я хочу прикрутить к нему логинку через ЕСИА. Что мне нужно сделать, чтобы получить такую возможность?
Иначе говоря, для сервиса, который авторизуется через ЕСИА, обязательно ли быть официальной "государственной услугой"? Где-то регистрироваться как провайдер сервиса?
antonvn
02.11.2023 12:58На это у Минцифры есть регламент, кого можно подключать, а кого нет. В последнее время шанс того, что вам дадут доступ, очень невелик, даже если вы и гос. контора и оператор ПДн. Тиндер для собак может даже и не пытаться.
Rursi
классная статья, спасибо!