Как интегрировать авторизацию через Госуслуги (ЕСИА) с помощью 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,    }  }  }

Надеюсь, статья оказалась для вас полезной. Желаю, чтобы у вас все получилось без особых проблем! 

Полезные материалы по теме


ссылка на оригинал статьи https://habr.com/ru/company/waves_ent/blog/666894/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *