Как интегрировать авторизацию через Госуслуги (ЕСИА) с помощью Docker и Typescript
Привет, Хабр! В одном из постов блога мой коллега Иван писал о нашем блокчейн-сервисе для онлайн-голосований 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,
}
}
}
Надеюсь, статья оказалась для вас полезной. Желаю, чтобы у вас все получилось без особых проблем!