Привет, Хабр! В одном из постов блога мой коллега Иван писал о нашем блокчейн-сервисе для онлайн-голосований WE.Vote. Он подробно разобрал, как работает WE.Vote с точки зрения технологий. Но чтобы сервисы удаленного голосования можно было использовать для принятия официальных решений юрлиц, не хватает еще одного важного компонента — достоверной верификации участников. В России для этого можно провести интеграцию с ЕСИА (Единой Системой Идентификации и Аутентификации) — проще говоря, с Госуслугами. Интеграция эта заметно отличается от интеграции с другими OAuth2-сервисами, как, например, Google или VK. В этом посте мы постараемся помочь тем, кто захочет интегрировать ЕСИА в свой сервис через стек, подобный нашему, а также дадим несколько полезных ссылок по ЕСИА в принципе.

Зачем нам ЕСИА?
Согласно Федеральному закону № 225-ФЗ от 28.06.2021 «О внесении изменений в часть первую Гражданского кодекса Российской Федерации», многие организаций в РФ получили право проводить официальные собрания и голосования по корпоративным вопросам дистанционно.
Ранее решения с юридической силой требовали очных собраний или голосований по почте. Голосования по почте не отличаются надежностью, а собрать много руководителей со всей России в одном месте — это кошмар с точки зрения затрат.
Чтобы проводить мероприятия принятия решения дистанционно в соответствии с новым федеральным законом, необходимо предоставить возможность достоверного установления личности участников. В России это возможно через проверку доступа к верифицированному аккаунту на Госуслугах.
Стек и схема интеграции
Для интеграции мы используем:
Typescript, ReactJS, NestJS
КриптоПро CSP 4
Docker, Kubernetes

Формирование подписи
Прежде чем разбирать все по порядку, кое о чем стоит подумать заранее. В отличие от других интеграций, запросы к ЕСИА должны сопровождаться подписью ГОСТ Р 34.10/11-2012, а не просто API key. Создать такую подпись можно с помощью утилиты КриптоПро CSP. Для нас основная задача здесь — правильно обернуть эту утилиту в Docker, чтобы с ней можно было работать как с отдельным сервисом в рамках нашей инфраструктуры. Получившийся сервис мы выложили в открытый доступ на гитхабе. Инструкция по запуску есть в README.md.
В процесс сборки Docker образа сервиса с утилитой КриптоПро мы встроили:
Установку утилиты КриптоПро СSP 4 из .deb пакета
Установку лицензии КриптоПро
Загрузку корневого сертификата тестовой или основной среды ЕСИА
Загрузку пользовательского сертификата с PIN кодом
REST-сервер с методом, позволяющим создавать подписи
Таким образом вся криптография собрана в отдельном самостоятельном компоненте, который можно использовать, когда необходимо что-нибудь подписать. Вот как это выглядит на бэкенде:
private async signParams(params: Record<string, string>) { const time = moment().format('YYYY.MM.DD HH:mm:ss ZZ') const state = uuid() const clientId = this.clientId const scope = this.scope const { data: { result: clientSecret } } = await axios.post<{ result: string }>( `${this.cryptoProServiceAddress}/cryptopro/sign`, { text: [scope, time, clientId, state].join('') }, ) return { ...params, timestamp: time, client_id: clientId, scope, state, client_secret: clientSecret.replace(/\n/g, ''), } }
С созданием подписей разобрались, теперь последовательно разберем, как реализовать схему выше.
Создание ссылки для редиректа на страницу ЕСИА
Все начинается с того, что пользователь решает пройти авторизацию через ЕСИА. Создаем на бэкенде ссылку для перехода с использованием нашего инструмента формирования подписей.
async getAuthLink(redirectLink: string) { const params = await this.signParams({ redirect_uri: redirectLink, response_type: 'code', access_type: 'offline', }) const authQuery = new URLSearchParams(params) const authURL = `${this.esiaHost}/aas/oauth2/ac` return `${authURL}?${authQuery}` }
В redirectLink необходимо указать адрес страницы, на которую ЕСИА перенаправит пользователя после успешной аутентификации. Созданную ссылку возвращаем на фронтенд и перенаправляем на нее пользователя.
Получение авторизационного токена ЕСИА
Запрос токена идентификации
После успешной аутентификации на странице ЕСИА пользователь возвращается на фронтенд приложения по указанному нами адресу. ЕСИА передаёт авторизационный токен в виде get-параметра code. Этот токен необходимо передать на бэкенд и запросить с его помощью идентификационный токен пользователя.
async getTokens(code: string) { try { const params = await this.signParams({ grant_type: 'authorization_code', token_type: 'Bearer', redirect_uri: 'no', code, }) const authURL = `${this.host}/aas/oauth2/te` const authQuery = new URLSearchParams(params) const { data: tokens } = await axios.post(`${authURL}?${authQuery}`) return { idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, } } catch (e) { const status = e.response ? e.response.status : 500 const message = e.response ? e.response.data.error_description : e.message throw new HttpException('Failed to get auth tokens: ' + message, status) } }
Получение данных о пользователе
Идентификационный токен пользователя необходимо проверить с помощью публичного RSA ключа от ЕСИА и получить из него id пользователя. С помощью этого id и accessToken, который мы получили в предыдущем шаге, мы уже наконец можем запросить персональные данные пользователя.
getUserIdFromToken(idToken: string) { const decodedIdToken = verify(idToken, this.esiaPublicKey, { algorithms: ['RS256'], audience: 'WE_VOTE', }) as EsiaParsedToken return decodedIdToken['urn:esia:sbj']['urn:esia:sbj:oid'] } async getUserInfo(tokens: EsiaTokens) { const { idToken, accessToken } = tokens const oId = this.getUserIdFromToken(idToken) const [{ data: mainInfo }, { data: contactsInfo }] = await Promise.all([ axios.get(`${this.esiaHost}/rs/prns/${oId}`, { headers: { Authorization: `Bearer ${accessToken}`, }, }), axios.get(`${this.esiaHost}/rs/prns/${oId}/ctts?embed=(elements)`, { headers: { Authorization: `Bearer ${accessToken}`, }, }), ]) const email = contactsInfo.elements.find(({ type }: { type: string }) => type === 'EML') return { id: oId, firstName: mainInfo.firstName, lastName: mainInfo.lastName, surName: mainInfo.middleName, trusted: mainInfo.trusted, email: email ? { value: email.value.toLowerCase(), verified: email.vrfStu === 'VERIFIED', } : null, } }
На этом шаге мы уже имеем все необходимые данные о пользователе. Остается только занести их в свою систему и закончить авторизацию.
Полный код интеграционного модуля на бэкенде
import { HttpException } from '@nestjs/common' import * as moment from 'moment' import { v4 as uuid } from 'uuid' import { URLSearchParams } from 'url' import axios from 'axios' import { verify } from 'jsonwebtoken' type EsiaTokens = { idToken: string, accessToken: string, } type EsiaParsedToken = { 'urn:esia:sbj': { 'urn:esia:sbj:oid': string, }, } export class EsiaApiService { private readonly clientId = 'WE_VOTE' private readonly scope = ['openid', 'email', 'fullname'].join(' ') constructor( private readonly esiaHost: string, // 'https://esia-portal1.test.gosuslugi.ru' или 'https://esia.gosuslugi.ru' private readonly esiaPublicKey: string, // можно взять из http://esia.gosuslugi.ru/public/esia.zip private readonly cryptoProServiceAddress: string, // адрес сервиса по созданию подписей e.g 'http://127.0.0.1:3037' ) { } async getAuthLink(redirectLink: string) { const params = await this.signParams({ redirect_uri: redirectLink, response_type: 'code', access_type: 'offline', }) const authQuery = new URLSearchParams(params) const authURL = `${this.esiaHost}/aas/oauth2/ac` return `${authURL}?${authQuery}` } private async signParams(params: Record<string, string>) { const time = moment().format('YYYY.MM.DD HH:mm:ss ZZ') const state = uuid() const clientId = this.clientId const scope = this.scope const { data: { result: clientSecret } } = await axios.post<{ result: string }>( `${this.cryptoProServiceAddress}/cryptopro/sign`, { text: [scope, time, clientId, state].join('') }, ) return { ...params, timestamp: time, client_id: clientId, scope, state, client_secret: clientSecret.replace(/\n/g, ''), } } async getTokens(code: string) { try { const params = await this.signParams({ grant_type: 'authorization_code', token_type: 'Bearer', redirect_uri: 'no', code, }) const authURL = `${this.esiaHost}/aas/oauth2/te` const authQuery = new URLSearchParams(params) const { data: tokens } = await axios.post(`${authURL}?${authQuery}`) return { idToken: tokens.id_token, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, } } catch (e) { const status = e.response ? e.response.status : 500 const message = e.response ? e.response.data.error_description : e.message throw new HttpException('Failed to get auth tokens: ' + message, status) } } getUserIdFromToken(idToken: string) { const decodedIdToken = verify(idToken, this.esiaPublicKey, { algorithms: ['RS256'], audience: this.clientId, }) as EsiaParsedToken return decodedIdToken['urn:esia:sbj']['urn:esia:sbj:oid'] } async getUserInfo(tokens: EsiaTokens) { const { idToken, accessToken } = tokens const oId = this.getUserIdFromToken(idToken) const [{ data: mainInfo }, { data: contactsInfo }] = await Promise.all([ axios.get(`${this.esiaHost}/rs/prns/${oId}`, { headers: { Authorization: `Bearer ${accessToken}`, }, }), axios.get(`${this.esiaHost}/rs/prns/${oId}/ctts?embed=(elements)`, { headers: { Authorization: `Bearer ${accessToken}`, }, }), ]) const email = contactsInfo.elements.find(({ type }: { type: string }) => type === 'EML') return { id: oId, firstName: mainInfo.firstName, lastName: mainInfo.lastName, surName: mainInfo.middleName, trusted: mainInfo.trusted, email: email ? { value: email.value.toLowerCase(), verified: email.vrfStu === 'VERIFIED', } : null, } } }
Надеюсь, статья оказалась для вас полезной. Желаю, чтобы у вас все получилось без особых проблем!
