Всем привет. Меня зовут Динис, я старший разработчик в БФТ-Холдинге.

В данной статье приведен план по созданию сервиса авторизации через систему ЕСИА. Тема будет интересна тем, кто внедряет такой сервис для своего приложения. В статье я собрал ключевые выдержки из документации с частями кода, а весь код вы можете найти в конце статьи по ссылке на 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 += "&timestamp=" + 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)


  1. Rursi
    02.11.2023 12:58
    +1

    классная статья, спасибо!


  1. dprotopopov
    02.11.2023 12:58
    +1

    и взаимодействует с алгоритмами формирования электронной подписи ГОСТ Р 34.10-2012 и криптографического хэширования ГОСТ Р 34.11-2012;

    а можно поподробнее - какое приложение-провайдер этой реализации использовалось

    например, .net у микрософта нет поддержки российских стандартов от слова совсем - соответственно, есть вариант использования криптопро - они пропатчили corefx для вызова своей аппликухи и выложили на github - но явно встраивать патченый net в бизнес-приложение не резон, но можно вынести в микросервис, заодно там же поднять и аппликуху от криптопро


    1. Dinis_S Автор
      02.11.2023 12:58

      в данном случае использовался провайдер vipnet, библиотека ViPNet JCrypto SDK, так же можно легко мигрировать на CryptoPro JCP.


      1. antonvn
        02.11.2023 12:58

        А почему не BouncyCastle? Там тоже поддержка ГОСТа давно есть.

        И вдогонку, все это хорошо работает, когда сертификаты и ключи в файликах. А если они на токене, и ключ неизвлекаем?


  1. aleksandy
    02.11.2023 12:58
    +1

    URLEncoder.encode("client_id", StandardCharsets.UTF_8.toString())

    Зачем?


    1. Dinis_S Автор
      02.11.2023 12:58

      бывают случаи, когда не всегда корректно парсится URL в виде строки, поэтому тут строки явно кдируются в URL


      1. aleksandy
        02.11.2023 12:58

        Ъ по ссылкам не ходят? Документацию не читают?

        Hidden text

        The alphanumeric characters "a" through "z", "A" through "Z" and "0" through "9" remain the same.


  1. olegchir
    02.11.2023 12:58
    +1

    Как вам вообще работа с ЕСИА? Нравится? Всё удобно и хорошо, или какие-то части выглядят сложными или неочевидными?


    1. Dinis_S Автор
      02.11.2023 12:58
      +1

      в целом работать с ЕСИА удобно, очень хорошая документация, открытое понятное API, бывали перебои(1-2 раза в год), но в течение 1-2 часа все исправлют.


      1. olegchir
        02.11.2023 12:58

        Представим, что у меня есть какой-то условный "Тиндер для собак", и я хочу прикрутить к нему логинку через ЕСИА. Что мне нужно сделать, чтобы получить такую возможность?

        Иначе говоря, для сервиса, который авторизуется через ЕСИА, обязательно ли быть официальной "государственной услугой"? Где-то регистрироваться как провайдер сервиса?


        1. antonvn
          02.11.2023 12:58

          На это у Минцифры есть регламент, кого можно подключать, а кого нет. В последнее время шанс того, что вам дадут доступ, очень невелик, даже если вы и гос. контора и оператор ПДн. Тиндер для собак может даже и не пытаться.


  1. evgajukov
    02.11.2023 12:58

    Эх, такую бы статью под nodejs