Создание сервиса авторизации через систему ЕСИА

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

В данной статье приведен план по созданию сервиса авторизации через систему ЕСИА. Тема будет интересна тем, кто внедряет такой сервис для своего приложения. В статье я собрал ключевые выдержки из документации с частями кода, а весь код вы можете найти в конце статьи по ссылке на 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: FSS-ESN
    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;
    }
}
  1. Реализация сервиса авторизации.

Общие принципы

Данная модель контроля доступа используется в случаях, когда система-клиент при доступе к ресурсу должна получить разрешение на это действие со стороны владельца ресурса.

В общем виде схема взаимодействия выглядит следующим образом:     

  • Система-клиент запрашивает у владельца ресурса разрешение на доступ к соответствующим ресурсам. Обычно этот запрос осуществляется не напрямую к владельцу ресурса, а через сервис авторизации (который, в свою очередь, запрашивает разрешение у владельца ресурса), поскольку сам владелец ресурса не может выдать ни маркер доступа, ни авторизационный код.

  • Система-клиент получает разрешение на доступ (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


Источник: https://habr.com/ru/companies/bft/articles/771320/


Интересные статьи

Интересные статьи

В конце месяца традиционно собрали для вас подборку ИБ-инцидентов. Сегодня в программе: инсайдер из японской телекоммуникационной компании, утечка данных сотрудников французского ритейлера и мошенница...
В самом начале у нас в Самокате была задача — внедрить практику нагрузочного тестирования на каждый релиз, чтобы минимизировать проблемы с производительностью на проде. А еще сделать это не потратив в...
Кинематограф стал неотъемлемой частью нашей жизни. Сопереживая героям, мы слово проживаем вторую жизнь, смотрим на мир глазами героев, начинаем смотреть на некоторые вещи иначе. Иногд...
В 1998 г. мало кому известный стартап под названием Netflix, только что запустивший собственный сайт, платил своим сотрудникам значительно меньше рынка: в фирму семейного типа шли не ...
Зачем?! Наверное, это первая эмоция большинства людей, прочитавших название статьи. Однако, давайте представим следующую ситуацию: в процессе исследования устройства вы доходите д...