Русский
Русский
English
Статистика
Реклама

Авторизация

Тонкости авторизации обзор технологии OAuth 2.0

21.09.2020 18:14:10 | Автор: admin
Информационная система Dodo IS состоит из 44 различных сервисов, таких как Трекер, Кассы ресторана или Базы знаний и многих других. 3 года назад мы написали сервис Auth для реализации сквозной аутентификации, а сейчас пишем уже вторую версию. В основе сервиса лежит стандарт авторизации OAuth 2.0. Он довольно сложный, но если будете работать над аналогичным сервисом, стандарт вам пригодится. В этой статье я постарался рассказать о стандарте максимально просто и понятно, чтобы вы сэкономили время на его изучение.



Задача Auth


Проблема авторизации в десятках сервисов встречалась ещё несколько лет назад в начале эпохи распила монолита. Эту проблему решили новым сервисом, который назвали Auth. Он помог реализовать бесшовную аутентификацию в различных сервисах и перенести данные о пользователях в отдельные базы данных.

У сервиса Auth есть три основные задачи:

  • Единая точка аутентификации (SSO) для всех сервисов системы. Сервисы не хранят учётные данные, а доверяют это одному выделенному сервису.
  • Безопасный и гранулированный доступ к ресурсам. Безопасный, потому что пароли хранятся в одном месте и максимально защищены. Гранулированный, так как владельцы сервисов могут настраивать доступ к ресурсам как они захотят, опираясь на данные, пришедшие из сервиса аутентификации.
  • Централизованное управление пользователями и доступом. Благодаря тому, что вся информация о пользователе хранится в сервисе аутентификации, мы можем управлять пользователями централизованно.

Проблемы


Первая версия Auth часть монолита. Он использует свой собственный протокол общения с сервисами. Такая схема была необходима в тот момент, но за несколько лет работы проявились проблемы.

Auth часть монолита. Следовательно, сервис привязан к релизному циклу, что лишает возможности независимой разработки и деплоя. Кроме того, придется разворачивать весь монолит, если захотелось развернуть Auth, например, при масштабировании сервиса.

Dodo IS зависит от Auth. В старой реализации внешние сервисы обращаются к Auth при каждом действии пользователя, чтобы валидировать данные о нём. Настолько сильная привязка может привести к остановке работы всей Dodo IS, если Auth приляжет по какой-то причине.

Auth зависит от Redis. Притом достаточно сильно неисправность работы Redisа приведёт к падению Authа. Мы используем Azure Redis, для которого заявленный SLA 99,9%. Это значит, что сервис может быть недоступен до 44 минут в месяц. Такие простои не позволительны.

Текущая реализация Auth использует свой протокол аутентификации, не опираясь на стандарты. В большинстве своих сервисов мы используем C# (если говорим о backend) и у нас нет проблем с поддержкой библиотеки для нашего протокола. Но если вдруг появятся сервисы на Python, Go или Rust, разработка и поддержка библиотек под эти языки потребует дополнительных затрат времени и принесет дополнительные сложности.

Текущий Auth использует схему Roles Based Access Control, которая базируется на ролях. Обычно с ролью выдаётся полный доступ к определённому сервису, вместо привязки к конкретному функционалу. Например, в пиццериях есть заместители управляющего, которые могут вести определенные проекты: составлять графики или учитывать сырьё. Но у нас нет выдачи прав на конкретные компоненты системы. Приходится выдавать полный доступ к сервису, чтобы сотрудники могли получить доступ к составлению графиков или настройкам какого-либо компонента учёта.

Проблемы подтолкнули к тому, чтобы спроектировать и написать новую версию Auth. На старте проекта мы потратили 3 недели только на изучение стандартов авторизации и аутентификации OAuth 2.0 и OpenID Connect 1.0.

Примечание. Утрированно, статья это пересказ RFC, который приходилось перечитывать несколько раз, чтобы понять, что происходит вокруг. Здесь я постарался уйти от этой сложности и рассказать всё максимально просто, структурировано, кратко и без описания сложных вещей, например, какие символы может содержать в себе ответ сервиса. В отличии от RFC, прочитав это один раз, можно во всём разобраться. Надеюсь, статья будет полезна и сэкономит время при выборе решения для реализации сервиса аутентификации, а может, кого-то заставит задуматься о его необходимости.

Что такое ОAuth2.0?


Разработку нового Auth мы решили начать с изучения доступных протоколов и технологий. Самый распространённый стандарт авторизации фреймворк авторизации OAuth2.0.

Стандарт был принят в 2012 году, и за 8 лет протокол меняли и дополняли. RFC стало настолько много, что авторы оригинального протокола решили написать OAuth 2.1, который объединит все текущие изменения по OAuth 2.0 в одном документе. Пока он на стадии черновика.

Актуальная версия OAuth описанна в RFC 6749. Именно его мы и разберем.

OAuth 2.0 это фреймворк авторизации.

Он описывает, как должно реализовываться взаимодействие между сервисами для обеспечения безопасной авторизации. Многие нюансы описаны достаточно подробно, например, flow взаимодействия узлов между собой, но некоторые отдаются на откуп конкретной реализации.

Особенности:

  • Разделение сущности пользователя и приложения, запрашивающего доступ. Благодаря этому разделению мы можем управлять правами приложения отдельно от прав пользователя.

  • Вместо привычных логина и пароля, которые имеют определенный набор прав и время жизни, мы получаем доступ к ресурсам с помощью случайно сгенерированных строк токенов.
  • Можно выдавать права максимально точечно, опираясь на собственные пожелания, а не на заранее определённый набор прав.

Разберёмся подробнее в особенностях.

Роли


В OAuth 2.0 определены четыре роли:

  • Resource owner сущность, которая имеет права доступа на защищённый ресурс. Сущность может быть конечным пользователем или какой-либо системой. Защищённый ресурс это HTTP endpoint, которым может быть что угодно: API endpoint, файл на CDN, web-сервис.
  • Resource server сервер, на котором хранится защищённый ресурс, к которому имеет доступ resource owner.
  • Client. Это приложение, которое запрашивает доступ к защищённому ресурсу от имени resource owner и с его разрешения с авторизацией.
  • Authorization server сервер, который выдаёт клиенту токен для доступа к защищённому ресурсу, после успешной авторизации resource owner.

Каждый участник взаимодействия может совмещать в себе несколько ролей. Например, клиент может быть одновременно resource owner, и запрашивать доступ к своим же ресурсам. Схему взаимодействия рассмотрим дальше.

Важно: клиент должен быть заранее зарегистрирован в сервисе. Как это сделать?

Регистрация клиента


Способ регистрации клиента, например, ручной или service discovery, вы выбираете сами, в зависимости от фантазии конкретной реализации. Но при любом способе при регистрации, кроме ID клиента, должны быть обязательно указаны 2 параметра: redirection URI и client type.

Redirection URI адрес, на который отправится владелец ресурса после успешной авторизации. Кроме авторизации, адрес используется для подтверждения, что сервис, который обратился за авторизацией, тот, за кого себя выдаёт.

Client type тип клиента, от которого зависит способ взаимодействия с ним. Тип клиента определяется его возможностью безопасно хранить свои учётные данные для авторизации токен. Поэтому существует всего 2 типа клиентов:

  • Confidential клиент, который может безопасно хранить свои учётные данные. Например, к такому типу клиентов относят web-приложения, имеющие backend.
  • Public не может безопасно хранить свои учётные данные. Этот клиент работает на устройстве владельца ресурса, например, это браузерные или мобильные приложения.

Токены


Токен в OAuth 2.0 это строка, непрозрачная для клиента. Обычно строка выглядит как случайно сгенерированная её формат не имеет значения для клиента. Токен это ключ доступа к чему-либо, например, к защищённому ресурсу (access token) или к новому токену (refresh Token).

У каждого токена своё время жизни. Но у refresh token оно должно быть больше, т.к. он используется для получения access token. Например, если срок жизни access token около часа, то refresh token можно оставить жить на целую неделю.

Refresh token опционален и доступен только для confedential клиентов. Пользуясь опциональностью токена, в некоторых реализациях время жизни access token сделано очень большим, а refresh token вообще не используется, чтобы не заморачиваться с обновлением. Но это не безопасно. Если access token был скомпрометирован, его можно обнулить, а сервис получит новый Access token с помощью refresh token. В случае, если refresh token нет, то потребуется проходить процесс авторизации заново.

За access token закреплён определённый набор прав доступа, который выдаётся клиенту во время авторизации. Давайте разберёмся, как выглядят права доступа в OAuth 2.0.

Права доступа


Права доступа выдаются клиенту в виде scope. Scope это параметр, который состоит из разделённых пробелами строк scope-token.

Каждый из scope-token представляет определённые права, выдающиеся клиенту. Например, scope-token doc_read может предоставлять доступ на чтение к какому-то документу на resource server, а employee доступ к функционалу приложения только для работников фирмы. Итоговый scope может выглядеть так: email doc_read employee.

В OAuth 2.0 мы сами создаём scope-token, настраивая их под свои нужды. Имена scope-token ограничиваются только фантазией и двумя символами таблицы ASCII " и \.

На этапе регистрации клиента, в настройках сервиса авторизации клиенту выдаётся стандартный scope по умолчанию. Но клиент может запросить у сервера авторизации scope, отличный от стандартного. В зависимости от политик на сервере авторизации и выбора владельца ресурса, итоговый набор scope может выглядеть совсем иначе. В дальнейшем, после авторизации клиента, владелец ресурсов может отобрать часть прав без повторной авторизации сервиса, но, чтобы выдать дополнительные разрешения, потребуется повторная авторизация клиента.

Абстрактный OAuth 2.0. Flow c применением Access token


Мы рассмотрели роли, рассмотрели виды токенов, а также как выглядят scope. Посмотрим на flow предоставления доступа к сервису.

Ниже представлена абстрактная схема (или flow) взаимодействия между участниками. Все шаги на данной схеме выполняются строго сверху вниз. Разберём детальнее.



  • Client отправляет запрос на доступ к требуемому ресурсу resource owner.
  • Resource owner передаёт обратно клиенту authorization grant, который подтверждает личность resource owner и его права на ресурс, доступ к которому запрашивает client. В зависимости от flow это может быть токен или учётные данные.
  • Client отправляет authorization grant, полученный в предыдущем шаге authorization server, ожидая от него Access token для доступа к защищённому ресурсу.
  • authorization server убеждается в валидности authorization grant, после чего отсылает access token клиенту в ответ.
  • Получив access token, клиент запрашивает защищённый ресурс у resource server.
  • Resource server убеждается в корректности access token, после чего предоставляет доступ к защищённому ресурсу.

Клиент получает одобрение от resource owner, на основе которого ему выдаётся доступ к ресурсу. Всё просто. А будет ли так же просто, если мы добавим в эту схему работу с refresh token?

Абстрактный OAuth 2.0. Flow c применением Refresh token


Первый и второй шаги опущены из данной схемы они ничем не отличаются от схемы абстрактного flow выше.



Схема подробнее:

  • Client приходит c authorization grant к authorization server и просит предоставить ему access token и refresh token.
  • Authorization server убеждается, что с authorization grant всё нормально и возвращает клиенту запрошенные access token и refresh token.
  • Client с access token запрашивает защищённый ресурс, пока не получит первую ошибку доступа к ресурсу invalid token error.
  • После получения ошибки доступа, клиент идет к authorization server с refresh token и просит заменить просроченный access token на новый.
  • В ответ клиент получает новый access token, а также новый refresh token, либо продлевается время жизни старого refresh token.

Что такое grant?


Grant это данные, которые представляют из себя успешную авторизацию клиента владельцем ресурса, используемые клиентом для полученияaccess token.

Например, когда мы где-либо аутентифицируемся с помощью Google, перед глазами всплывает уведомление. В нём говорится, что такой-то сервис хочет получить доступ к данным о вас или к вашим ресурсам (выводятся запрашиваемые scope-token). Это уведомление называется Consent Screen.

В момент, когда нажимаем ОК, в базу данных попадает тот самый grant: записываются данные о том, что такой-то пользователь дал такие-то доступы такому-то сервису. Клиент получает какой-то идентификатор успешной аутентификации, например строку, которая ассоциируется с данными в базе данных.

Существует 4 + 1 способа получения grant grant type:

  • Authorization code используется для confedencial клиентов web-сервисов.
  • Client credentials используется для confedential клиентов, которые запрашивают доступ к своим ресурсам или ресурсам, заранее согласованным с сервером авторизации.
  • Implicit использовался public-клиентами, которые умеют работать с redirection URI (например, для браузерных и мобильных приложений), но был вытеснен authorization code grant с PKCE (Proof Key for Code Exchange дополнительная проверка, позволяющая убедиться, что token получит тот же сервис, что его и запрашивал. Прочитать подробнее RFC 7636).
  • Resource owner password credentials. В RFC 6819, посвящённому безопасности в OAuth 2.0, данный тип grant считается ненадёжным. Если раньше его разрешалось использовать только для миграции сервисов на OAuth 2.0, то в данный момент его не разрешено использовать совсем.
  • Device authorization (добавлен в RFC 8628) используется для авторизации устройств, которые могут не иметь веб-браузеров, но могут работать через интернет. Например, это консольные приложения, умные устройства или Smart TV.

Актуальными можно считать только authorization code (с PKCE), client credentials и device authorization grant, но мы рассмотрим все. Рассматривать grant будем в порядке возрастания сложности понимания.

Client credentials grant flow


Имеет самый простой flow, напоминающий обычную авторизацию на любом сервисе. Она выполняется с помощью учётных данных клиента, которые представляют собой client id и client secret аналог логина и пароля для пользователя. Так как для аутентификации требуется client secret, который должен соответствующе храниться, данный flow могут использовать только confedential клиенты.



Схема проста: клиент аутентифицируется на сервере авторизации передавая client id и client secret. В ответ получает access token, с которым уже может получить доступ к нужному сервису.

Этот flow требуется, когда клиент пытается получить доступ к своим ресурсам или ресурсам, заранее согласованным с сервером авторизации. Например, сервису А нужно время от времени ходить в сервис Б и актуализировать там данные о количестве пиццерий в сети.

Resource owner password credentials flow


По текущим рекомендациям безопасности описанных в данном RFC, данный flow не рекомендуется использовать вовсе из-за явных проблем с безопасностью.



Resource owner передаёт свой логин и пароль клиенту, например, через формы на клиенте. Клиент, в свою очередь, с помощью него получает access token (и, опционально, refresh token).

Здесь есть проблема. Resource owner просто берёт и отдаёт в открытом виде свой логин и пароль клиенту, что не безопасно. Изначально он был сделан только для клиентов, которым вы доверяете или тех, что являются частью операционной системы. Позже он был разрешён только для миграции с аутентификации по логину и паролю на OAuth 2.0. Текущие рекомендации по безопасности запрещают его использование.

Authorization code


Самый распространённый flow на данный момент. В основном используется для confidential клиентов, но с появлением дополнительной проверки с помощью PKCE, может применяться и для public-клиентов.

В данном flow взаимодействие client с resource owner проходит через user-agent (браузер). К user-agent есть одно требование: он должен уметь работать с HTTP-редиректами. Без этого resource owner не сможет попасть к серверу авторизации и вернуться обратно с grant.



Данный flow сложнее, чем предыдущие, поэтому будем разбирать по шагам. Для начала представим, что мы resource owner и перешли на страницу сервиса онлайн-обучения, который хочет сохранять результаты обучения к нам в облако. Ему требуется получить доступ к нашему ресурсу, например, определённой директории в облаке. Мы нажимаем на Авторизоваться и начинается путешествие по Authorization code grant flow:

  • На первом шаге клиент перенаправляет resource owner с помощью user-agent на страницу аутентификации Authorization server. В URI он указывает client ID и redirection URI. Redirection URI используется для понимания, куда вернуть resource owner после того, как авторизация пройдёт успешно (resource owner выдаст разрешение на scope, запрашиваемый клиентом).
  • Взаимодействуя с сервером авторизации через user-agent, resource owner проходит аутентификацию на сервере авторизации.
  • Resource owner проверяет права, которые запрашивает клиент на consent screen и разрешает их выдачу.
  • Resource owner возвращается клиенту с помощью user-agent обратно на URI, который был указан как redirection URI. В качестве query-параметра будет добавлен authorization code строка, подтверждающая то, что resource owner выдал необходимые права сервису.
  • С этим authorization code клиент отправляется на сервер авторизации, чтобы получить в ответ access token (ну и refresh token, если требуется).
  • Сервер авторизации валидирует authorization code, убеждаясь, что токен корректный и выдаёт клиенту access token (и опционально refresh token). С его помощью клиент сможет получить доступ к заветному ресурсу.

Если представить нас на месте resource owner, то мы видим просто перенаправление на сервер авторизации, аутентифицируемся, подтверждаем доступ на Consent screen и нас отправляет на уже работающий сервис. Например, мы проходим это много раз, когда заходим на сервис под учётной записью Google, Facebook или Apple.

Следующий flow построен на основе этого.

Implicit grant


Это оптимизация Authorization code grant flow для public-клиентов, которые умеют работать с redirection URI. Например, для браузерных приложений на JavaScript, или мобильных приложений. Требование к user-agent, с помощью которого взаимодействуют клиент и resource owner, сохраняется: он должен уметь работать с HTTP-редиректами.

Между authorization code и implicit есть основное отличие: вместо получения authorization code и access token по нему, мы сразу получаем access token после успешной авторизации resource owner. Кроме того, здесь не используется client secret из соображений безопасности приложение можно дизассемблировать и получить его. Подлинность проверяется только по redirection URI.



Многие шаги из данной схемы похожи на шаги из authorization code, но предлагаю их разобрать также подробно. Представим, что некое браузерное приложение хочет сохранять свои настройки в нашем Git-репозитории. Мы нажимаете Войти в GitHub и на этом этапе начинается работа Implicit flow:

  • Клиент с помощью user-agent и HTTP-редиректа перенаправляет resource owner на сервер авторизации. В параметрах запроса передает client ID и redirection URI, которые нужны для аутентификации клиента и последующего возврата resource owner обратно.
  • Resourse owner аутентифицируется, взаимодействуя через user-agent с сервером авторизации. Заодно подтверждает выдачу grant клиенту, с client ID которого он пришёл.
  • После подтверждения выдачи grant (нажатия allow на consent screen), user-agent возвращает resource owner на redirection URI. Кроме того, в URI fragment передаётся access token (URI fragment это то, что обычно идёт в URI после символа #).
  • Сам фрагмент сохраняется локально в user-agent. User-agent двигается дальше по redirection URI за web-страницей, которая нужна для получения access token и других необходимых данных из фрагмента. Она может находиться как на самом клиенте, так и на удалённом ресурсе, например, на CDN.
  • Web-ресурс возвращает web-страницу (может содержать в себе скрипт), которая может прочитать полностью redirection URI, в том числе и значение, указанное в фрагменте.
  • User-agent отрисовывает локально полученную страницу, включая исполнение скриптов, которые он получил от web-hosted client resource, которые получают access token.
  • Полученный access token user-agent просто передаёт клиенту.

Это сложный flow. Он мало используется в реальных сценариях. Но его всё ещё можно встретить в legacy-проектах.

Device authorization (RFC 8628)


С 2012 до 2019 появилось много умных устройств, на которых неудобно авторизоваться. Например, неудобно вводить сложный логин и пароль на телевизоре каждый раз при открытии ресурса. На некоторых устройствах это невозможно, например на серверных ОС без графического интерфейса. В августе 2019 этот flow появился как раз для таких сценариев.

Есть, как минимум, 3 требования к устройствам, чтобы работа с помощью Device authoraztion grant flow была возможна:

  • Устройство должно иметь возможность совершать исходящие HTTPS-запросы.
  • Устройство должно иметь возможность отображать URI и код пользователю.
  • Каждое авторизуемое устройство принадлежит resource owner, который для успешной авторизации должен иметь другое устройство с браузером, чтобы перейти по указанному URI и ввести указанный код.



Возможно, схема кажется сложной из-за обилия стрелок. Разберём её также пошагово, как и разбирали сложные flow до него.

Представим, что мы пытаемся авторизоваться на web-сервисе с помощью телевизора. Мы видим кнопку Авторизоваться как устройство и нажимаем. В этот момент начинается наш Device flow:

  • Телевизор делает запрос на сервер авторизации, передавая ему свой client ID.
  • Сервер авторизации убеждается, что такой клиент зарегистрирован и имеет соответствующий тип grant.
  • Если всё хорошо, то Authorization server возвращает device code, user code и verification URI. Device code это уникальный идентификатор устройства, которое авторизуется в системе.
  • Устройство отображает user code и verification URI владельцу этого устройства resource owner. Redirection URI может быть передан как строкой, так и с помощью QR-кода ограничений нет.
  • После того, как устройство отобразило user code и verification URI, оно начинает раз в некоторое время опрашивать сервер авторизации о её успешности.
  • Дальше в бой вступает resource owner. Он переходит по указанному verification URI, аутентифицируется и вводит user code, который он получил от устройства, подтверждая выдачу необходимых scope устройству. На этом действия от имени resource owner закончены.
  • Всё это время устройство (пункт 3) опрашивало сервер авторизации о её успешности. Устройство в очередной раз идёт к серверу авторизации со своим device code и client ID в надежде, что авторизация на этот раз прошла.
  • В этот раз, когда resource owner подтвердил передачу необходимых прав устройству, сервер авторизации возвращает в ответе на запрос access token (если предусмотрено настройками сервера и refresh token). И с помощью токена устройство уже может продолжать работу с ресурсом.

Несмотря на кажущуюся сложность со стрелочками, этот flow тоже достаточно прост. Если вам требуется взаимодействовать с устройствами (а у нас их много: трекер, касса, витрины и прочие устройства), то вам стоит использовать этот flow.

Вместо вывода


В этой статье я опустил много подробностей, чтобы максимально просто и доступно рассказать о самом важном. Например, типы запросов, как и в каком виде передавать параметры, какие символы допустимы в качестве значений для того.

Если хотите погрузиться в тематику детальнее, то рекомендую в RFC 6749 (для OAuth 2.0) и RFC 8628 (для Device Flow). Кроме того, следить за актуальными версиями RFC можно на ресурсе, посвящённому OAuth.

Если статья была полезна и захотите подробностей пишите в комментариях, и в следующих статьях расскажу о PKCE, о протоколе аутентификации OpenID Connect 1.0, о нашей реализации сервера аутентификации и многом другом.

Полезные ссылки:

Подробнее..

Перевод Как я воровал данные с пользовательских аккаунтов в Google

29.01.2021 12:17:38 | Автор: admin
Вы со мной не знакомы, но существует известная вероятность, что я знаком с вами. Причина в том, что у меня есть полный, неограниченный доступ к приватной информации миллионов людей, размещённой на аккаунтах Google. Отправленные по почте выписки по банковским счетам, медицинские документы, хранящиеся на Google Drive, сохранённые и пересланные чаты из Facebook, голосовые сообщения на Google Voice, личные фотографии на Google Photos. Список можно продолжать. Никто из них не знает об этом сейчас и никогда не узнает в будущем. Возможно, в их число входите и вы.

И как же я такое провернул? Всё началось с разработанного мной приложения. По очевидным причинам, обнародовать название я не стану. Приложение довольно нехитрое, оно рассчитано на людей, увлекающихся фитнесом, и предлагает возможности типа внесения данных о скорости во время пробежки или готовых комплексов силовых упражнений. Как и многие другие продукты, оно требует, чтобы пользователь первым делом создал аккаунт. По данным аналитики, примерно 60% людей вместо того, чтобы полностью проходить процедуру регистрации, соблазняются заманчивой кнопкой Войти с Google.



Вы, наверное, в общих чертах знаете, что происходит в таких случаях: когда пользователь нажимает на кнопку, внутри приложения открывается браузерное окошко входа в аккаунт Google.



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



Вот, собственно, и всё. Теперь человек может спокойно пользоваться приложением, а я, между тем, получаю полный, неограниченный доступ к его аккаунту с удалённого сервера. Ему никогда не придёт никаких сообщений по этому поводу. А если он окажется из дотошных и начнёт изучать сетевой трафик, то увидит, что устройство направляло сетевые запросы только и исключительно на различные поддомены google.com.

Но как такое вообще возможно? Давайте вернёмся к нашей кнопке Войти с Google. Сразу проясним одну вещь: для тех, кто не в курсе, после нажатия этой кнопки приложение может сделать всё что угодно. Запустить процесс авторизации в Google, издать трубный глас, показать гифку с котиком. Не все варианты из этого списка равно вероятны, но помечтать-то можно.

В моём случае, по клику на кнопку приложение при помощи WebView открывает диалоговое окно и задаёт веб-адрес: accounts.google.com/EmbeddedSetup. Он действительно соответствует странице входа в аккаунт Google, только особой, рассчитанной на новые устройства Android. Это обстоятельство сыграет свою роль позже, когда нам любезно предоставят всю необходимую информацию в виде cookie.

К сожалению, эта страница и выглядит, и действует иначе, чем стандартная страница авторизации (по крайней мере, такая, какой она должна быть по умолчанию):



Обратите внимание на странную синюю полоску, слова Learn more и примерно всё на правой картинке

И вот теперь-то начинается веселье. Я использую стандартные API, встроенные как в iOS, так и в Android, чтобы внедрить тщательно прописанный фрагмент кода на Javascript, который произведёт необходимые модификации, чтобы страница не отличалась от стандартной ни своим видом, ни поведением.

Догадливые сейчас подумают: Стоп, так раз можно внедрить код на JavaScript, что мешает просто похитить логин и пароль прямо из текстовых полей?. Абсолютно ничего вообще говоря, для этой цели уже и готовый код существует. Но в наше время доступа к логину и паролю уже недостаточно. Разве что очень повезёт и сервер окажется в радиусе нескольких сотен миль от местоположения пользователя. В противном случае пользователь получит письмо и оповещение с сообщением о подозрительной активности и попытка взлома будет пресечена. А двухфакторная авторизация усложняет нам жизнь ещё сильнее.

Так что давайте поговорим о чём-нибудь другом, например, о мастер-токене. На первый взгляд, выглядит как-то недобро, а на второй оказывается ещё хуже, чем казалось.

Когда на устройстве Android впервые проводится процедура авторизации, он отсылает токен, полученный от вышеупомянутой встроенной страницы входа в аккаунт, на особый endpoint. Вот пример типичного запроса:

POST /auth HTTP/1.1Content-Type: application/x-www-form-urlencodedContent-Length: 349Host: android.clients.google.comConnection: Keep-AliveUser-Agent: GoogleLoginService/1.3 (a10 JZO54K);gzipapp: com.google.android.gmsapp=com.google.android.gms&client_sig=38918a453d07199354f8b19af05ec6562ced5788&callerPkg=com.google.android.gms&callerSig=38918a453d07199354f8b19af05ec6562ced5788&service=ac2dm&Token=oauth2_4%2F4AY0e-l5vPImYAf8XsnlrdshQQeNir3rSBx5uJ2oO9Tfl17LpsaBpGf1E2euc18UyOc8MnM&ACCESS_TOKEN=1&add_account=1&get_accountid=1&google_play_services_version=204713000


Токен в этом запросе берётся из cookies страницы входа в аккаунт, а всё остальное информация, которая находится в открытом доступе (спасибо, microG!). Та же страница входа в аккаунт улаживает дела с двухфакторной авторизацией нам вообще не приходится ничего предпринимать.

После этого вышеупомянутый endpoint отсылает тот самый мастер-токен. Но как бы мне получить к нему доступ без подозрительных сетевых запросов? Очень просто: через лог в Google Firebase.

А мастер-токен это мощная штука. У него неограниченный срок действия при условии, что пользователь не меняет пароль или настройки двухфакторной идентификации. Насколько мне известно, он не подвергается никаким проверкам безопасности, невзирая на местоположение, IP и производимые действия. Он никогда не провоцирует систему на отправление пользователю уведомления или письма.

И главное: он открывает мне путь ко всем без исключения сервисам, которые когда-либо были доступны с мобильного устройства, от лица владельца соответствующего аккаунта. Достаточно одного запроса POST, чтобы я мог прикинуться официальным аккаунтом Google и обзавестись OAuth-токеном для доступа к чему угодно, включая частные (и, скорее всего, нигде не опубликованные) API. Я могу читать письма, бродить по Google Drive, просматривать бэкапы с телефона и фотографии на Google Photos, а заодно ознакомиться с веб-историей пользователя и поболтать с его друзьями по Google Messenger. Я даже создал модифицированную версию microG, с которой могу управлять всеми этими пользовательскими аккаунтами непосредственно из обычных приложений Google.

И напоминаю, весь процесс выглядит вот так. Предлагаю всем задаться вопросом: а вы бы попались?

Разоблачение


Как многие из вас уже догадались, не всё в этой статье правда. Я не публиковал никаких фитнес-приложений на Play Store и не собирал миллионы мастер-токенов. Спасибо этому материалу за вдохновение. Но сам метод работает. Я, да и любой другой разработчик, определённо мог бы сделать приложение с таким сюрпризом (возможно, кто-то уже и сделал).

FAQ


Но ведь страница отличается от нормального входа в аккаунт. Я бы заметил!

Отличия не так уже бросаются в глаза, так что, скорее всего, не заметили бы. Страница входа в аккаунт Google на Android, как правило, имеет интерфейс типа выберите аккаунт, но бывают и исключения например, многие веб-приложения, вроде тех, которые делают на Ionic и Cordova. Большинство iOS-приложений тоже часто отдают предпочтение веб-версии, очень напоминающей приведённый вариант. Кроме того, даже если вам кажется, что отсутствие экрана с такое-то приложение просит доступ, вас точно насторожит, то его вполне можно внедрить ценой нескольких лишних часов работы.

Это и на iOS работает?

Я не пробовал, но нет оснований считать, что не сработает.

И что с этим делать?

Вообще, вопрос сложный. Ни одно из моих действий, строго говоря, не подпадает под определение эксплойта, но результат, тем не менее, несёт большую опасность. Для начала Google неплохо бы разобраться со своими уведомлениями насчёт входа с нового устройства, чтобы они нормально работали. Лично я их получаю, когда пытаюсь зайти в аккаунт с компьютера, но, пока тестировал это приложение, система не сработала ни разу. Другая хорошая идея обновить гайдлайны, в том, что касается кнопок Войти с Google; сейчас там вообще ничего не говорится о требованиях к реализации. Возможно, им стоило бы углубиться в дебри безопасности через неясность этот принцип, несмотря на все свои недостатки, пока что отлично служит Apple для обеспечения безопасности в iMessage.

Вынужден признать: у меня нет уверенности, что тут можно найти техническое решение, которое полностью устранило бы проблему. Если у официального приложения Google есть возможность выполнить какое-то действие, значит, и сторонние программы при должном старании смогут его повторить. Впрочем, в компании работают неглупые люди, так что поживём увидим.

Эта проблема актуальна для всех систем авторизации в сторонних приложениях?

Вполне вероятно. Я не разбирался досконально, в каких случаях рассылаются оповещения, а в каких нет, но даже когда оповещения приходят, из них не всегда понятно, что происходит. Функция Войти с Apple, как бы там ни было, снабжена очень жёстким гайдлайном, причём администрация App Store (где функция, полагаю, в основном и используется) строго отслеживает выполнение требований. С другой стороны, у них свои проблемы с авторизацией, на фоне которых эта меркнет.

Реальная история


Пусть это были не миллионы, но небольшое количество мастер-токенов у ничего не подозревающих пользователей я как-то раз действительно собрал, причём совершенно непреднамеренно.

Реальная история моего прозрения началась с того, что я разработал приложение-проигрыватель Carbon Player; сейчас оно уже кануло в лету, так и не получив широкого распространения. Приложение замышлялось как замена Google Play Music (помните времена, когда такое существовало?), только с дизайном в разы круче. Чтобы получить доступ к пользовательской папке с музыкой, я перевёл gmusicapi Саймона Вебера на Java, но, переписывая код, поначалу особо не вникал, как там устроен процесс авторизации. Понял только, что нужны логин и пароль пользователя, которые я запрашивал через незамысловатое диалоговое окошко, а потом идут какие-то запросы и вываливаются какие-то токены, которые мне подходят для извлечения музыки.



Перед тем как передать первую версию приложения небольшой группе тестировщиков, я прочесал код, везде добавил логирование и ещё внедрил интерцептор, который должен был автоматически загружать все логи на Firebase. Конечно, не логать пароли мне ума хватило, но три токена, полученные моей имплементацией gmusicapi, я по ошибке залогал. Два из них были довольно безобидными давали доступ только к разным хранилищам музыки. А вот третий оказался мастер-токеном.

В общем, приложение за всё время своего существования собрало от силы двадцать пять скачиваний, и я быстро махнул на него рукой, чтобы не отвлекаться от учёбы. Но перед этим успел выпустить пару обновлений, в одном из которых появился редизайн новой отпадной (ну, по тем временам) домашней страницы Google Play Music одного из немногих элементов исходного продукта, которые неплохо смотрелись.



Процесс оказался намного заморочнее, чем я думал, и пришлось неожиданно много заниматься обратной разработкой Protocol Buffers. Что важнее, по какой-то причине теперь там требовался совершенно иной токен, который в gmusicapi реализован уже не был. В итоге, чтобы его внедрить, я на несколько часов зарылся в систему авторизации, пытаясь разобраться, как она устроена. Это привело к ужасному моменту прозрения, когда я осознал, что логировал самую секретную информацию, какую только можно. Скажу одно: логирование прекратилось. Двадцать пять человек, которые скачали приложение, простите меня, пожалуйста (ваши токены я с Firebase удалил!).

Был ещё один, не связанный с первым случай, когда я работал в стартапе, создававшем менеджер паролей. Одним из ключевых преимуществ приложения было то, что оно хранило пароли строго на телефоне, но при этом позволяло авторизоваться с компьютера благодаря букмарклету на JavaScript, который соединял девайсы через QR-код. Чтобы всё проходило гладко, когда пользователь открывал сайт на компьютере, приложение обращалось к тому же сайту с телефона и внедряло тщательно прописанный фрагмент кода на JavaScript, который фиксировал логины, пароли и всё прочее. Знакомо звучит?

В конце концов, эти две идеи срослись у меня в голове. У меня был создан прототип Carbon Player, но не хватало времени взять его в работу. Спустя несколько лет я наконец начал создавать на его базе что-то вроде демо-версии. В процессе пришлось многое изменить метод, описанный в этой статье, значительно отличается от того, что было реализовано в прототипе, поскольку Google внёс изменения в систему авторизации. Но конечный итог остаётся прежним и пугает не меньше, чем тогда.

Если хотите, можете скачать демо-версию и посмотреть на систему в действии; даю слово, что на облако ничего не логируется. Имейте в виду, что приложение очень простое и практически не тестировалось, так что есть немалая вероятность, что метод не сработает, если у вашего аккаунта иная конфигурация. Спасибо, что прочитали статью, надеюсь, вы получили удовольствие от небольшого напоминания о том, насколько важно ставить всё под сомнение. Даже самые безобидные вещи иногда таят внутри что-то не слишком приятное (хотя в случае с тортом-мороженым бывает и наоборот).
Подробнее..

ГибкаяавторизацияспомощьюCasbinиPERM.Практический пример

02.02.2021 02:11:38 | Автор: admin

После написания предыдущей статьи по языку PERM и библиотеке Casbin, возникли вопросы. Причем не у одного человека, и я хотел ответить сначала в комментарии, но понял, что объем материала выходит за рамки обычного комментария, поэтому изложу этот ответ в виде отдельной статьи.


Я долго не мог понять ту конкретную конструкцию, которая сидела в голове у вопрошающих, но в конечном итоге после уточняющих вопросов получил ответы, компиляцию которых приведу в цитате.


А как с такими DSL решается задача показать список объектов, которые я могу видеть? Надо же в SQL-запрос это как-то транслировать, не выгребать же все записи с БД.

Есть интерфейс на сайте, показывающий список чего-либо. Скажем статей в админке CMS. Статей с базе десятки тысяч, но обычно пользователь имеет доступ только к десятку. Как достать из БД статьи, которые видны конкретному пользователю? Ну, если мы все правила что кому видно вынули из кода в какой-то DSL?
Другими словами как написать запрос типа

select * from articles a
join roles r on r.userId = currentUserId
where article.owner = currentUserId
OR (r.role in ['admin', 'supevisor']) админ всего
OR (r.domain = currentDomainId AND r.role in ['domain-admin', 'domain-supervisor']) админ домена

У меня такие правила в коде, в виде LINQ-выражений, и я такую задачу решать умею. А возникает такая задача даже чаще, чем проверить есть ли доступ к одному выгруженному с память объекту

Надеюсь, я правильно понял эту конструкцию и в ходе обратного реверсинженеринга, мне удалось вытащить исходные данные для решения данной задачи. Для начала обойдемся без использования мультитенатности (домены), так как они усложняют задачу и соответственно понимание. Пример их использования я приводил в прошлой статье.


Сначала я опишу ту реализацию, которая сидит в голове у вопрошающего, чтобы вы тоже ее понимали, а затем мы эту реализацию трансформируем в решение с использованием Casbin.


Описание задачи


У нас имеется CMS, в которую пользователи через админпанель могут добавлять статьи. В адмике пользователь с ролью user может видеть только свои статьи. Статьидругихлюдейоннеможетвидеть, если только ему не присвоена роль admin или supervisor. Пользователь с ролью supervisor может видеть и редактировать все статьи, а admin имеет все те же права что и supervisor, но кроме этого, еще может и удалять любые статьи.


Структура, схема и содержимое БД:


Структура БД нашей CMS:
Схема таблиц БД


Содержимое таблицы пользователей Users
Содержимое таблицы Users


Пользователям присвоены следующие роли
Содержимое таблицы Roles
Содержимое таблицы Roles


Как мы видим, Piter является администратором, а Bob супервизором. Alice обычный пользователь, может видеть, создавать и редактировать только свои статьи.


Содержимое таблица с статьями Articles
Содержимое таблицы Articles


Исходя из вопроса, выборка осуществляется для администратора (Piter, id=3) таким образом:


select * from articles aleft join roles r on r.userId = 3where a.owner = 3OR (r.role in ('admin', 'supevisor'))

Результат выборки для администратора


Выборка для супервизора (Bob, id=2) таким образом:


select * from articles aleft join roles r on r.userId = 2where a.owner = 2OR (r.role in ('admin', 'supevisor'))

Результат выборки для супервизора


А выборка для пользователя (Alice, id=1) выглядит так:


select * from articles aleft join roles r on r.userId = 1where a.owner = 1OR (r.role in ('admin', 'supevisor'))

Результат выборки для пользователя


Давайте теперь попробуем использовать другой подход к авторизации, на базе библиотеки Casbin.


Подход с использованием Casbin


Для начала давайте определимся что ресурс в подходе PERM это не столько экземпляр сущности, сколько сама сущность.
Т.е. когда мы описываем модель авторизации, под ресурсом в нашем примере подразумевается сама сущность (таблица) Статья. А не конкретная запись из этой таблицы (с Id=1 например).


Дальше необходимо уточнить, что те роли, которые используются в описании этой задачи это не классические роли из подхода RBAC.
Роли RBAC описывают те разрешения, которые можно выполнить с сущностью. Например в классическом RBAC роль user могла бы только читать статьи, роль author могла бы наследовала роль user (т.е. чтение статей), и еще могла бы редактировать создавать новые статьи, а роль admin могла бы наследовать все предыдущие разрешения и плюс еще удалять статьи.
В описанной же нами выше задаче, по сути эти все роли не отличаются друг от друга. И user и supervisor и admin имеют одни и те же права, один набор разрешений каждый носитель любой из ролей может создавать, редактировать или удалять статьи. Разница только в области видимости, user может видеть в админке только свои статьи, и соответственно редактировать их и удалять. А admin и supervisor не только свои, но еще и чужие.
И в этом заключается большой минус модели RBAC, так это статичная модель авторизации, и с ее помощью вообще невозможно выразить бизнес-правила, в которых используются атрибуты, значения которых заранее не известны и вычисляются в процессе работы.
Об этом подробно уже было рассказано в статье Подходы к контролю доступа: RBAC vs. ABAC


А те роли что мы используем (user, admin) это так называемые динамические роли. Термин не официальный, и зачастую каждый подразумеваем под ним свое, они реализуются различными способами, и описанное автором решение которое я привел в начале статьи один из таких подходов.


Выборка значений с учетом "динамических ролей"


Для начала давайте определим модель политики RBAC (rbac_model.conf), ее подробное описание я привел в предыдущей статье:


[request_definition]r = sub, obj, act[policy_definition]p = sub, obj, act[role_definition]g = _, _[policy_effect]e = some(where (p.eft == allow))[matchers]m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

Далее, мы убираем таблицу ролей Roles. Роли у нас теперь будут описаны в хранилище политик. Это может быть как обычный *.csv файл, так и таблица в базе данных. Для простоты я буду использовать cvs файл rbac_policy.csv:


p, user, article, readp, user, article, modifyp, user, article, createp, user, article, deleteg, supervisor, userg, admin, supervisorg, 1, userg, 2, supervisorg, 3, admin

Суть здесь такая, что мы даем роли user права на чтение, модификацию, создание и удаление статей. Затем роль supervisor наследует права роли user. А роль admin наследует права роли supervisor.
Далее пользователю alice(1) мы присваиваем роль user, bob(2) у нас supervisor, а piter(3) admin
В принципе этого достаточно, чтобы решить проблему, которую описывал автор вопроса.


Этот код конечно не для продакшена, а для демонстрации. Для продакшена я советую использовать cross-cutting concern с CQRS+MediatR


    public IList<Article> GetArticlesForAdminPanel(int currentUserId)    {        var e = new Enforcer("CasbinConfig/rbac_model.conf", "CasbinConfig/rbac_policy.csv");        var obj = "article";        var act = "read";        //Сначала проверяем, что пользователь имеет права на чтение статей        if (e.Enforce(currentUserId.ToString(), obj, act))        {            //Получаем список ролей пользователя            var currentUserRoles = e.GetRolesForUser(currentUserId.ToString());            //Проверяем, является ли пользователем админиом или супервизором            var isAdmin = currentUserRoles.Any(x => x == "admin" || x == "supervisor");            //Если админ, вернуть все записи, иначе только те, которые принадлежат пользователю            if (isAdmin) return _context.Articles.ToList();            else return _context.Articles.Where(x => x.OwnerId == currentUserId).ToList();        }        else        {            // отклонить запрос, показать ошибку            throw new Exception("403. У вас нет прав для чтения статей");           }    }

Тадам! Задача решена, ответ на вопрос дан.


Редактирование статей с учетом "динамических ролей"


Теперь же пойдем еще дальше. Мы получили список статей, отобразили в админке, и попытаемся отредактировать какую-нибудь статью. И нам соответственно надо проверить, имеем ли мы права чтобы ее отредактировать, пользователь с ролью user может отредактировать только свои статьи, supervisor и admin могут отредактировать все статьи.


Для этого определяем новую модель, называем ее rbac_with_abac_model.conf:


[request_definition]r = sub, obj, act[policy_definition]p = sub, obj, act[role_definition]g = _, _[policy_effect]e = some(where (p.eft == allow))[matchers]m = (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "supervisor")) && g(r.sub, p.sub) && r.act == p.act

Данная модель не сильно отличается от модели чтения, за исключением секции [matchers], в ней мы конструкцию r.obj == p.obj заменили на (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "supervisor")). Это следует читать как r.sub (id пользователя) должен совпадать с полем r.obj.OwnerId (id владельца обновляемой записи) или r.sub должен входить принадлежать группе "supervisor". Поскольку группа admin наследует все права группы supervisor то и члены группы admin будут соответствовать этому правилу.


Файл с политиками остается прежним, его мы не меняем. Теперь смотрим как это выглядит в коде:


    public void UpdateArticle(int currentUserId, Article newArticle)    {        var e = new Enforcer("CasbinConfig/rbac_with_abac_model.conf", "CasbinConfig/rbac_policy.csv");        var act = "modify";        //Проверяем, что пользователь имеет права на редактирование статьи        if (e.Enforce(currentUserId.ToString(), newArticle, act))        {            //Обновляем, и сохраняем изменения            _context.Articles.Update(newArticle);            _context.SaveChanges();        }        else        {            // отклонить запрос, показать ошибку            throw new Exception("403. Недостаточно прав");        }    }

Здесь стоит обратить внимание на то, что мы в метод e.Enforce передаем вторым параметром объект, который представляет из себя экземпляр класса Article.


Ну и последний шаг попытаемся удалить статью.


Удаление статьи


Бизнес-правило у нас здесь такое, что пользователь с ролью user может удалить свою статью, supervisor не имеет прав удалять чужие статьи, а admin такое право имеет.
Опишем теперь это бизнес-правило в модели политики PERM, в файле delete_model.conf:


[request_definition]r = sub, obj, act[policy_definition]p = sub, obj, act[role_definition]g = _, _[policy_effect]e = some(where (p.eft == allow))[matchers]m = (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "admin")) && g(r.sub, p.sub) && r.act == p.act

Она не сильно отличается от предыдущей политике редактирования, за тем исключением, что удалять чужие статьи мы разрешили только роли admin. Если носитель роли supervisor попытается удалить чужую статью, у него выйдет ошибка превышения полномочий.


Как и в случае с моделью, код мало чем отличается от предыдущего примера на редактирование:


    public void DeleteArticle(int currentUserId, Article deleteArticle)    {        var e = new Enforcer("CasbinConfig/delete_model.conf", "CasbinConfig/rbac_policy.csv");        var act = "delete";        //проверяем, что пользователь имеет права на удаление статьи        if (e.Enforce(currentUserId.ToString(), deleteArticle, act))        {            //Удаляем статью            _context.Articles.Remove(deleteArticle);            _context.SaveChanges();        }        else        {            // отклонить запрос, показать ошибку            throw new Exception("403. Недостаточно прав");        }    }

Резюме


Надеюсь, данный пример продемонстрировал гибкость, универсальность и удобство использования библиотеки Casbin и языка PERM для построения решений по разделению доступа и авторизации.


Еще отмечу, что как сами модели политики, так и правила политики могут храниться в БД. И предусмотрена возможность фильтрации правил, если их большое множество, и это может стать узким местом в высоконагруженных приложениях.


Casbin под капотом использует библиотеку DynamicExpresso.Core для интерпретации простых выражений C# при сопоставлении правил политик с входными значениями, что позволяет эффективно использовать Casbin даже в самых сложных сценариях авторизации.


Не смотря на свою молодость, Casbin активно развивается, используется во множестве проектов, обрастает полезными инструментами и API. Такими например как UI для управления политиками.


Полностью работоспособный и самодостаточный код примера, который я использовал для написания данной статьи я разместил у себя на Github, можете скачать и поиграться, если есть интерес и желание.


Полезные ссылки


Подробнее..

Авторизация и аутентификация на NodeJs и Socket.io и проблемы вокруг

19.01.2021 12:23:47 | Автор: admin

На текущий момент я работаю в компании МегаФон тимлидом фронта. С начала 2020 года мы в команде МегаФона разрабатываем собственную платформу Интернета вещей. Так как в таком процессе нагрузка на бэк-энд разработчиков стала колоссальной, а фронт не так активно задействован, внутри отдела было принято решение отдать всю веб-часть в руки моей команды. Очевидно, что мы взяли NodeJs с ExpressJS, и занялись построением серверной архитектуры.

Для корректного доступа к собранным с устройств данным нужна была авторизация, чтобы понимать, кто и что может получить/сделать. Про очевидный путь с passportJs, думаю, нет смысла рассказывать. По логину и паролю мы стали отдавать jwt токен, и изначально на этом успокоились.

После этого нам потребовалось хранить в сессии данные, специфичные для каждого пользователя. Логичным решением было бы использовать сам jwt токен, хранить информацию в нем и гонять от клиента к серверу. Однако, данное решение не подходило нам из-за использования веб-сокетов (в нашем случае мы взяли socket.io), так как в данном протоколе передача хедера Authorization с jwt токеном невозможна (в соответствии со стандартом). Единственный вариант - передавать хедер в параметрах url. Но это не очень здорово - токены будут легко видны во всех логах всех прокси-серверов. Хорошим решением оказалось использование сессии, которая хранится полностью на серверной стороне, и по сети ходит лишь id этой сессии. Мы выбрали - express-session.

Объединенная сессия

Отдельной проблемой стала необходимость получения актуального состояния сессии и возможность его изменения в событиях веб-сокетов. Для этого идеально подошел пакет - express-socket.io-session. Правда, пришлось поколдовать над её подключением:

Изменили подключение сессии и настройки кук:

this.store = new pgSession({          pool: pgPool,          tableName: SESSION_TABLE      });this.session = expressSession({    name: SESSION_KEY,    secret: SESSION.secret,    resave: false, // важно, для того, чтобы сессия не перезаписывалась на каждый чих    rolling: true,    saveUninitialized: true, // нужно для выдачи куки даже неавторизированному пользователю    proxy: true,    cookie: {        secure: true, // обязывает производить передачу по ssl        maxAge: SESSION_DURATION,        sameSite: 'none' // чтобы можно было отдавать на разные поддомены    }    store: this.store});

Мы написали обработчики сессии таким образом, чтобы сессия подгружалась до начала обработки события, и сохранялась, если необходимо:

const asyncHandlerExtended = (fn, socket) => (data) => {    const cb = async () => {        await reloadSession(socket.handshake.session);        await fn({ socket, data });        await saveSession(socket.handshake.session);    };    return Promise.resolve(cb()).catch((err) => {        socket.emit('error', err);    });};

Собрали все вместе при настройке сокетов:

import sharedSession from 'express-socket.io-session';import io from 'socket.io';const resultSocket = nameSpace ? this.io.of(nameSpace) : this.io;resultSocket.use(sharedSession(session, { autoSave: true }));

Разделение сокетов по ролям

Дальше нам нужно понимание того, кому и какие события можно получать на сокетах, а какие - нет. Для этого отлично подходит механизм комнат в socket.io. Он позволяет серверу формировать пространства, в которые можно "запускать" пользователей и эмитить в них разные события. Мы выделили под каждую из ролей пользователей отдельную комнату (например комната adminRoom - пространство для событий, которые могут идти/поступать только для администраторов), а общее пространство теперь у нас считается "публичным" и доступно для всех подключенных, но не авторизованных пользователей. Таким образом, процесс получения доступов на сокетах выглядит так:

  1. Клиент аутентифицируется по http, по паре логин/пароль, получает в ответ jwt токен и куку с id сессии.

  2. Далее юзер коннектится к нашей точке входа для socket.io (например: localhost:8080/sockets). Теперь у него есть доступ до публичных событий на наших сокетах.

  3. Если он хочет получить доступ до всех наших событий, которые ему доступны по роли, то он отправляет событие auth_login по сокетам, с jwt токеном, который он получил от http авторизации.

  4. Система проверяет токен и по результатам проверки генерирует одно из двух событий.

    1. auth_loginFailed - пользователю не будут предоставлены доступы, так как токен кривой или просрочен

    2. auth_loginSuccess - все хорошо, можно продолжать

  5. Если проверка прошла успешно, то сервер добавляет пользователя во все пространства предоставленные ему по его роли.

  6. Пользователю теперь доступны аутентифицированные и скрытые ранее события.

  7. Когда у токена проходит "срок годности", сокеты генерируют событие auth_expire, говорящее, что более пользователю недоступны ранее предоставленные комнаты.

Token steal

Вишенкой на торте в данной картине механизма авторизации/аутентификации стал результат изучения проблемы кражи токенов. Ради минимизации рисков от попадания в такую ситуацию, было решено улучшить механизмы работы с авторизационным токеном. Во время исследования данной темы наткнулся на статью на хабре - Зачем нужен Refresh Token, если есть Access Token?. Очень советую ознакомиться, но если кратко, то вот результирующая цитата:

Таким образом, схема refresh + access токен ограничивает время, на которое атакующий может получить доступ к сервису. По сравнению с одним токеном, которым злоумышленник может пользоваться неделями и никто об этом не узнает.

Однако, у нас уже есть два токена:

  • id серверной сессии от express-session, который ходит в куках, всегда

  • jwt токен, который генерируется после логина пользователя

Поэтому было бы логично реализовать похожий механизм, как и в OAuth2. Для этого мы стали хранить jwt токен в сессии и сравнивать его с полученным от клиента при проверке аутентификации. Остается только одна проблема - несколько токенов в одной сессии. Необходимо это для того, чтобы иметь возможность войти и в админку, и на фронт. Для этого считаем, что клиент передаст свое "название" при логине, и под этим названием мы и сохраним токен в сессию, а также записываем само название внутрь токена для дальнейшей проверки. За счет вышеописанного получается следующая схема взаимодействия:

  1. Клиент отправляет пару логин и пароль, плюс к этому уникальный ключ - название себя (то есть имя приложения).

  2. Система, если пара логин и пароль найдена, генерирует jwt токен, включая в него название клиента (ключ), отправляет его клиенту и записывает в сессию.

  3. При последующем обращении клиента по роуту, который закрыт проверкой аутентификации, проверяется наличие токена, его валидность, просрочен ли он. Далее сервер вытаскивает из токена название клиента и смотрит, есть ли такой токен с таким ключом в текущей сессии.

  4. Если вдруг токен и куку украл злоумышленник, то при первом же рефреше с любой из сторон (клиент или злоумышленник), сторона, которая попытается обратиться со старой парой токен + кука, получит негативный результат. Система поймет, что токен не соответствует тому, что хранит сессия и очистит сессию полностью, что привет к выбросу и клиента и злоумышленника, а факт кражи будет зафиксирован в системе.

  5. Если же все хорошо, то само собой мы отдадим данные =)

Таким образом, мы получили многосессионный механизм (один пользователь может зайти сразу с нескольких браузеров в одно приложение) аутентификации, который работает еще и несколько приложений сразу.

В заключение

Надеюсь, описанная выше логика работы поможет читателям реализовать достаточно защищенные веб-сервера. На текущий момент в статье приведено мало снипетов кода, так как логика разбросана по разным файлам и частям кода, и собрать ее воедино будет проблематично. Но если будет проявлен интерес в комментариях к решению той или иной проблемы, я постараюсь дополнить статью тем или иным куском кода, примером и т.п.

Подробнее..

Keycloak интеграция со Spring Boot и Vue.js для самых маленьких

10.05.2021 18:20:09 | Автор: admin

Вы больше не можете создать сервер авторизации с помощью @EnableAuthorizationServer, потому что Spring Security OAuth задеприкейтили, а проект Spring Authorization Serverвсё ещё экспериментальный? Выход есть! Напишем авторизацию своими руками... Что?.. Нет?.. Не хочется? И вообще получаются какие-то костыли и велосипеды? Ну ладно, тогда давайте возьмём уже что-то готовое. Например, Keycloak.

Что, зачем и почему?

Как-то сидя на карантине захотелось мне написать pet-проект, да не простой, а с использованием микросервисной архитектуры (ну или около того). На начальном этапе одного сервиса для фронта и одного для бэка, в принципе, будет достаточно. Если вдруг в будущем понадобятся ещё сервисы, то будем добавлять их по мере необходимости. Для бэка будем использовать Spring Boot. Для фронта - Vue.js, а точнее Vuetify, чтобы не писать свои компоненты, а использовать уже готовые.

Начнём с авторизации. В качестве протокола авторизации будем использовать OAuth2, т.к. стильно, модно, молодёжно, да и использовать токены для получения доступа к сервисам, одно удовольствие, особенно в микросервисной архитектуре.

Для авторизации пусть будет отдельный сервис. И раз уж мы решили использовать Spring Boot, то сможет ли он нам чем-то помочь в создании этого сервиса? Например, каким-нибудь готовым решением, таким как Authorization Server? Правильно, не сможет. Проект Spring Security OAuth в котором находился Authorization Server задеприкейтили, а сам проект Authorization Server стал эксперементальным и на данный момент находится в активной разработке. Что делать? Как быть? Можно написать свой сервис авторизации. Если подсматривать в исходники задеприкейченого Authorization Server, то, вероятно, задача будет не такой уж и страшной. Правда, при этом возможны ситуации когда реализацию каких-то интересных фич будет негде подсмотреть и решать вопросы о том "быть или не быть", "правильно ли так делать или чё-то фигня какая-то" придётся исходя из собственного опыта, что может привести к получению на выходе большого количества неприглядных костылей.

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

Keycloak

Keycloak представляет из себя сервис, который предназначен для идентификации и контроля доступа. Что он умеет:

  • SSO (Single-Sign On) - это когда вы логинитесь в одном едином месте входа, получаете идентификатор (например, токен), с которым можете получить доступ к различным вашим сервисам

  • Login Flows - различные процессы по регистрации, сбросу пароля, проверки почты и тому подобное, а так же соответствующие страницы для этих процессов

  • Темы - можно кастомизировать страницы для Login Flows

  • Social Login - можно логиниться через различные социальные сети

  • и много чего ещё

И всё это он умеет практически из коробки, достаточно просто настроить требуемое поведение из админки (Admin Console), которая у Keycloak тоже имеется. А если вам всего этого вдруг окажется мало, то Keycloak является open sourceпродуктом, который распространяется по лицензии Apache License 2.0. Так что можно взять исходники Keycloak и дописать требуемый функционал, если он вам, конечно, настолько сильно нужен.

А ещё у Keycloak имеются достаточно удобные интеграции со Spring Boot и Vue.js, что значительно упрощает разработку связанную с взаимодействием с ним.

Getting Started with Keycloak

Запускать локально сторонние сервисы, требуемые для разработки своих собственных, лично я предпочитаю с помощью Docker Compose, т.к. наглядно и достаточно удобно в yml-файле описывать как и с какими параметрами требуется осуществлять запуск. А посему, Keycloak локально будем запускать с помощью Docker Compose.

В качестве докер-образа возьмём jboss/keycloak. Чтобы иметь возможность обращаться к Keycloak прокинем из контейнера порт 8080. Так же, чтобы иметь возможность заходить в админку Keycloak, требуется установить логин и пароль от админской учётки. Сделать это можно установив переменные окружения KEYCLOAK_USER для логина и KEYCLOAK_PASSWORD для пароля. Итоговый файл приведен ниже.

# For developmentversion: "3.8"services:  keycloak:    image: jboss/keycloak:12.0.2    environment:      KEYCLOAK_USER: admin      KEYCLOAK_PASSWORD: admin    ports:      - 8080:8080

Создание своих realm и client

Для того чтобы иметь возможность из своего клиентского приложения обращаться к Keycloak, например, для аутентификации или авторизации, нужно в Keycloak создать клиента (client), который будет соответствовать этому приложению. Клиента в Keycloak можно создать в определённом realm. Realm - это независимая область в которую входят пользователи, клиенты, группы, роли и много чего ещё.

По умолчанию уже создан один realm и называется он master. В нём будет находится админская учётка логин и пароль от которой мы задали при запуске Keycloak с помощью Docker Compose. Данный realm предназначен для администрирования Keycloak и он не должен использоваться для ваших собственных приложений. Для своих приложений нужно создать свой realm.

Для начала нам нужно залогиниться в админке Keycloak, запустить который можно с помощью файла Docker Compose, описанного ранее. Для этого можно перейти по адресу http://localhost:8080/auth/ и выбрать Administration Console.

После этого мы попадаем на страницу авторизации админки Keycloak. Здесь можно ввести логин и пароль от админской учётки для входа в Keycloak.

После входа откроется страница настроек realm master.

Давайте создадим свой realm. Для этого необходимо навести курсор на область с названием realm, чтобы появилась кнопка Add realm.

На странице создания realm достаточно заполнить только поле Name.

После нажатия на кнопку Createмы попадём на страницу редактирования этого realm. Но пока дополнительно в нашем realm ничего менять не будем.

Теперь перейдём в раздел Clients. Как можно заметить, по умолчанию уже создано несколько технических клиентов, предназначенных для возможности администрирования через Keycloak, например, для того чтобы пользователи могли менять свои данные или чтобы можно было настраивать realm'ы с помощью REST API и много для чего ещё. Подробнее про этих клиентов можно почитать тут.

Давайте создадим своего клиента. Для этого в разделе Clientsнеобходимо нажать на кнопку Create.

На странице создания клиента необходимо заполнить поля:

  • Client ID - идентификатор клиента, будет использоваться в различных запросах к Keycloak для идентификации приложения.

  • Root URL - адрес клиентского приложения.

После нажатия на кнопку Saveмы попадём на страницу редактирования этого клиента. Настройки клиента менять не будем, оставим их такими, какими они были выставлены по умолчанию.

Интеграция со Spring Boot

В первую очередь давайте создадим проект на Spring Boot. Сделать это можно, например, с помощью Spring Initializr. В качестве системы автоматической сборки проекта будем использовать Gradle. В качестве языка пусть будет Java 15. Никаких дополнительных зависимостей в соответствующем блоке Dependencies добавлять не требуется.

Для того чтобы в Spring Boot проекте появилась поддержка Keycloak, необходимо добавить в него Spring Boot Adapter и добавить в конфиг приложения конфигурацию для Keycloak.

Для того чтобы добавить Spring Boot Adapter, необходимо в проект подключить зависимость org.keycloak:keycloak-spring-boot-starter и сам adapter org.keycloak.bom:keycloak-adapter-bom. Сделать это можно изменив файл build.gradle следующим образом:

...dependencyManagement {imports {mavenBom 'org.keycloak.bom:keycloak-adapter-bom:12.0.3'}}dependencies {implementation 'org.springframework.boot:spring-boot-starter-web'implementation 'org.keycloak:keycloak-spring-boot-starter'testImplementation 'org.springframework.boot:spring-boot-starter-test'}...
Проблемы в Java 14+

Если запустить Spring Boot приложение на Java 14 или выше, то при обращении к вашим методам API, закрытым ролями кейклока, будут возникать ошибки видаjava.lang.NoClassDefFoundError: java/security/acl/Group. Связано это с тем, что в Java 9 этот, а так же другие классы из этого пакета были задеприкейчины и удалены в Java 14. Исправить данную проблему, вроде как, собираются в 13-й версии Keycloak. Чтобы решить её сейчас, можно использовать Java 13 или ниже, либо, вместо сервера приложений Tomcat, который используется в Spring Boot по умолчанию, использовать, например, Undertow. Для того чтобы подключить в Spring Boot приложение Undertow, нужно добавить в build.gradle зависимость org.springframework.boot:spring-boot-starter-undertow и исключить зависимоситьspring-boot-starter-tomcat.

...dependencies {implementation('org.springframework.boot:spring-boot-starter-web') {exclude module: 'spring-boot-starter-tomcat'}implementation ('org.keycloak:keycloak-spring-boot-starter') {exclude module: 'spring-boot-starter-tomcat'}implementation 'org.springframework.boot:spring-boot-starter-undertow'testImplementation 'org.springframework.boot:spring-boot-starter-test'}...

Теперь перейдём к конфигурации приложения. Вместо properties файла конфигурации давайте будем использовать более удобный (на мой взгляд, конечно же) yml. А так же, чтобы подчеркнуть, что данный конфиг предназначен для разработки, профиль dev. Т.е. полное название файла конфигурации будет application-dev.yml.

server:  port: 8082keycloak:  auth-server-url: http://localhost:8080/auth  realm: "list-keep"  resource: "list-keep"  bearer-only: true  security-constraints:    - authRoles:        - uma_authorization      securityCollections:        - patterns:            - /api/*

Давайте подробнее разберём данный конфиг:

  • server

    • port - порт на котором будет запущенно приложение

  • keycloak

    • auth-server-url - адрес на котором запущен Keycloak

    • realm - название нашего realm в Keycloak

    • resource - Client ID нашего клиента

    • bearer-only - если выставлено true, то приложение может только проверять токены, и в приложении нельзя будет залогиниться, например, с помощью логина и пароля из браузера

    • security-constraints - для описания ролевой политики

      • authRoles - список ролей Keycloak

      • securityCollections

        • patterns - URL-паттерны для методов REST API, которые требуется закрыть соответствующими ролями

      В данном конкретном случае мы закрываем ролью uma_authorization все методы, в начале которых присутствует путь /api/. Звёздочка в конце паттерна означает любое количество любых символов. Роль uma_authorization добавляется автоматически ко всем созданным пользователям, т.е. по сути данная ролевая политика означает что все методы /api/* доступны только авторизованным пользователям.

В общем-то, это все настройки которые нужно выполнить в Spring Boot приложении для интеграции с Keycloak. Давайте теперь добавим какой-нибудь тестовый контроллер.

@RestController@RequestMapping("/api/user")public class UserController {    @GetMapping("/current")    public User getCurrentUser(            KeycloakPrincipal<KeycloakSecurityContext> principal    ) {        return new User(principal.getKeycloakSecurityContext()                .getToken().getPreferredUsername()        );    }}
User.java
public class User {    private String name;    public User(String name) {        this.name = name;    }    public String getName() {        return name;    }    public void setName(String name) {        this.name = name;    }}

В данном контроллере есть лишь один метод /api/user/current, который возвращает информацию по текущему юзеру, а именно Preferred Username из токена. По умолчанию в Preferred Username находится username пользователя Keycloak.

Исходники проекта можно посмотреть тут.

Интеграция с Vue.js

Начнём с создания проекта. Создать проект можно, например, с помощью Vue CLI.

vue create list-keep-front

После ввода данной команды необходимо выбрать версию Vue. Т.к. в проекте будет использоваться библиотека Vuetify, которая на данный момент не поддерживает Vue 3, нужно выбрать Vue 2.

После этого нужно перейти в проект и добавить Vuetify.

vue add vuetify

После добавления Vuetify вместе с самой библиотекой в проект будут добавлены каталоги components и assets. В components будет компонент HelloWorld, с примером страницы на Vuetify, а в assets ресурсы, использующиеся в компоненте HelloWorld. Эти каталоги нам не пригодятся, поэтому можно их удалить.

Для удобства разработки сконфигурируем devServer следующим образом: запускать приложение будем на порту 8081, все запросы, которые начинаются с /api/ будем проксировать на адрес, на котором запущенно приложение на Spring Boot.

module.exports = {  devServer: {    port: 8081,    proxy: {      '^/api/': {        target: 'http://localhost:8082'      }    }  }}

Перейдём к добавлению в проект поддержки Keycloak. Для начала обратимся к официальной документации. Там нам рассказывают о том, что в проект нужно добавить Keycloak JS Adapter. Сделать это можно с помощью библиотеки keycloak-js. Добавим её в проект.

yarn add keycloak-js

Далее нам предлагают добавить в src/main.js код, который добавит в наш проект поддержку Keycloak.

// Параметры для подключения к Keycloaklet initOptions = {  url: 'http://127.0.0.1:8080/auth', // Адрес Keycloak  realm: 'keycloak-demo', // Имя нашего realm в Keycloak  clientId: 'app-vue', // Идентификатор клиента в Keycloak    // Перенаправлять неавторизованных пользователей на страницу входа  onLoad: 'login-required'}// Создать Keycloak JS Adapterlet keycloak = Keycloak(initOptions);// Инициализировать Keycloak JS Adapterkeycloak.init({ onLoad: initOptions.onLoad }).then((auth) => {  if (!auth) {    // Если пользователь не авторизован - перезагрузить страницу    window.location.reload();  } else {    Vue.$log.info("Authenticated");        // Если авторизован - инициализировать приложение Vue    new Vue({      el: '#app',      render: h => h(App, { props: { keycloak: keycloak } })    })  }  // Пытаемся обновить токен каждые 6 секунд  setInterval(() => {    // Обновляем токен, если срок его действия истекает в течении 70 секунд    keycloak.updateToken(70).then((refreshed) => {      if (refreshed) {        Vue.$log.info('Token refreshed' + refreshed);      } else {        Vue.$log.warn('Token not refreshed, valid for '          + Math.round(keycloak.tokenParsed.exp          + keycloak.timeSkew - new Date().getTime() / 1000) + ' seconds');      }    }).catch(() => {      Vue.$log.error('Failed to refresh token');    });  }, 6000)}).catch(() => {  Vue.$log.error("Authenticated Failed");});

С инициализацией Keycloak JS Adapter, вроде бы, всё понятно. А вот использование setInterval для обновления токенов мне показалось не очень практичным и красивым решением. Как минимум, кажется, что при бездействии пользователя на странице токены всё равно продолжат обновляться, хоть это и не требуется. На мой взгляд, обновление токенов лучше сделать так, как предлагает, например, автор данной статьи. Т.е. обновлять токены когда пользователь выполняет какое-либо действие в приложении. Автор указанной статьи выделяет три таких действия:

  • Взаимодействие с API (бэкендом)

  • Навигация (переход по страницам)

  • Переход на вкладку с нашим приложением, например, из другой вкладки

Приступим к реализации. Для того чтобы можно было обновлять токен из различных частей приложения, нам понадобится глобальный экземпляр Keycloak JS Adapter. Для этого во Vue.js существует функционал плагинов. Создадим свой плагин для Keycloak JS Adapter в файле /plugins/keycloak.js.

import Vue from 'vue'import Keycloak from 'keycloak-js'const initOptions = {    url: process.env.VUE_APP_KEYCLOAK_URL,    realm: 'list-keep',    clientId: 'list-keep'}const keycloak = Keycloak(initOptions)const KeycloakPlugin = {    install: Vue => {        Vue.$keycloak = keycloak    }}Vue.use(KeycloakPlugin)export default KeycloakPlugin

Значение адреса Keycloak, указанное в initOptions.url, может отличаться в зависимости от того где запущенно приложение (локально, на тесте, на проде), поэтому, чтобы иметь возможность указывать значения в зависимости от среды, будем использовать переменные окружения. Для локального запуска можно создать файл .env.local в корне проекта со следующим содержимым.

VUE_APP_KEYCLOAK_URL = http://localhost:8080/auth

Теперь нам достаточно импортировать файл с созданным нами плагином в main.js, и мы сможем из любого места приложения обратиться к нашему Keycloak JS Adapter с помощью Vue.$keycloak. Давайте это и сделаем, а так же создадим экземпляр Vue нашего приложения. Для этого изменим файл main.js следующим образом.

import Vue from 'vue'import App from './App.vue'import vuetify from './plugins/vuetify'import router from '@/router'import i18n from '@/plugins/i18n'import '@/plugins/keycloak'import { updateToken } from '@/plugins/keycloak-util'Vue.config.productionTip = falseVue.$keycloak.init({ onLoad: 'login-required' }).then((auth) => {  if (!auth) {    window.location.reload();  } else {    new Vue({      vuetify,      router,      i18n,      render: h => h(App)    }).$mount('#app')    window.onfocus = () => {      updateToken()    }  }})

Помимо инициализации Keycloak JS Adapter, здесь добавлен вызов функции updateToken() на событие window.onfocus, которое будет возникать при переходе пользователя на вкладку с нашим приложением. Наша функция updateToken() вызывает функцию updateToken() из Keycloak JS Adapter и, соответственно, обновляет токен, если срок жизни токена в секундах на данный момент меньше, чем значение TOKEN_MIN_VALIDITY_SECONDS, после чего возвращает актуальный токен.

import Vue from 'vue'const TOKEN_MIN_VALIDITY_SECONDS = 70export async function updateToken () {    await Vue.$keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS)    return Vue.$keycloak.token}

Теперь добавим обновление токена на оставшиеся действия пользователя, а именно на взаимодействие с API и на навигацию. С API мы будем взаимодействовать с помощью axios. Помимо обновления токена нам в каждом запросе необходимо добавлять http-хидер Authorization: Bearer с нашим токеном для авторизации в нашем Spring Boot сервисе. Так же давайте будем перенаправлять на какую-нибудь страницу с ошибками, например, /error, если API будет возвращать нам ошибки. Для того чтобы выполнять какие-либо действие на любые запросы/ответы в axios существуют интерцепторы, добавить которые можно в App.vue.

<template>  <v-app>    <v-main>      <router-view></router-view>    </v-main>  </v-app></template><script>import Vue from 'vue'import axios from 'axios'import { updateToken } from '@/plugins/keycloak-util'const AUTHORIZATION_HEADER = 'Authorization'export default Vue.extend({  name: 'App',  created: function () {    axios.interceptors.request.use(async config => {      // Обновляем токен      const token = await updateToken()      // Добавляем токен в каждый запрос      config.headers.common[AUTHORIZATION_HEADER] = `Bearer ${token}`      return config    })        axios.interceptors.response.use( (response) => {      return response    }, error => {      return new Promise((resolve, reject) => {        // Если от API получена ошибка - отправляем на страницу /error        this.$router.push('/error')        reject(error)      })    })  },  // Обновляем токен при навигации  watch: {    $route() {      updateToken()    }  }})</script>

Помимо интерцепторов мы здесь добавили наблюдателя (watch), который будет отслеживать переход пользователя по страницам приложения и обновлять при этом токен.

Интеграция с Keycloak закончена. Давайте теперь добавим тестовую страницу /pages/Home.vue, на которой будем вызывать с помощью axios тестовый метод /api/user/current, который мы ранее добавили в Spring Boot приложение, и выводить имя полученного пользователя.

<template>  <div>    <p>{{ user.name }}</p>  </div></template><script>import axios from 'axios'export default {  name: 'Home',  data() {    return {      user: {}    }  },  mounted() {    axios.get('/api/user/current')        .then(response => {          this.user = response.data        })  }}</script>

Для того чтобы можно было попасть на данную страницу в нашем приложении необходимо добавить её в router.js. Данная страница будет доступна по пути /.

import Vue from 'vue'import VueRouter from 'vue-router'import Home from '@/pages/Home'import Error from '@/pages/Error'import NotFound from '@/pages/NotFound'Vue.use(VueRouter)let router = new VueRouter({    mode: 'history',    routes: [        {            path: '/',            component: Home        },        {            path: '/error',            component: Error        },        {            path: '*',            component: NotFound        }    ]})export default router

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

И ещё немного о страницах

Помимо страницы /pages/Home.vue в роутере присутствуют страницы /pages/Error.vue и /pages/NotFound.vue. НаError , как уже упоминалось ранее, происходит переход из интерцептора при получении ошибок от API. На NotFound - если будет переход на неизвестную страницу.

Для примера давайте рассмотрим содержимое страницы Error.vue. Содержимое NotFound.vue практически ничем не отличается.

<template>  <v-container      class="text-center"      fill-height      style="height: calc(100vh - 58px);"  >    <v-row align="center">      <v-col>        <h1 class="display-2 primary--text">          {{ $t('oops.error.has.occurred') }}        </h1>        <p>{{ $t('please.try.again.later') }}</p>        <v-btn            href="http://personeltest.ru/aways/habr.com/"            color="primary"            outlined        >          {{ $t('go.to.main.page') }}        </v-btn>      </v-col>    </v-row>  </v-container></template><script>export default {  name: 'Error'}</script>

В шаблоне данной страницы используется локализация. Работает она с помощью плагина vue-i18n. Для того чтобы прикрутить локализацию своих текстовок нужно добавить переводы в виде json файлов в проект. Например, для русской локализации можно создать файл ru.json и положить его в каталог locales. Теперь эти текстовки необходимо загрузить в VueI18n. Сделать это можно, например, следующим образом. Давайте код по загрузке текстовок вынесем в/plugins/i18n.js.

import Vue from 'vue'import VueI18n from 'vue-i18n'Vue.use(VueI18n)function loadLocaleMessages () {    const locales = require.context('@/locales', true,                                    /[A-Za-z0-9-_,\s]+\.json$/i)    const messages = {}    locales.keys().forEach(key => {        const matched = key.match(/([A-Za-z0-9-_]+)\./i)        if (matched && matched.length > 1) {            const locale = matched[1]            messages[locale] = locales(key)        }    })    return messages}export default new VueI18n({    locale: 'ru',    fallbackLocale: 'ru',    messages: loadLocaleMessages()})

После этого к этим текстовкам можно будет обращаться из шаблона страницы с помощью $t.

Так же привожу содержимое /plugins/vuetify.js. В нём добавлена возможность использовать иконки Font Awesome на страницах нашего приложения.

import Vue from 'vue'import Vuetify from 'vuetify/lib/framework'import 'vuetify/dist/vuetify.min.css'import '@fortawesome/fontawesome-free/css/all.css'Vue.use(Vuetify);const opts = {    icons: {        iconfont: 'fa'    }}export default new Vuetify(opts)
Немного мыслей об обработке ошибок

Функции Keycloak JS Adapter init() и updateToken() возвращают объект KeycloakPromise, у которого есть возможность вызывать catch() и в нём обрабатывать ошибки. Но лично я не понял что именно в данном случае будет считаться ошибками и когда мы попадём в этот блок, т.к., например, если Keycloak не доступен, то в этот блок мы не попадаем. Поэтому в приведённом здесь приложении, я возможные ошибки от этих двух функций не обрабатываю. Возможно, если Keycloak не работает, то в продакшене стоит делать так, чтоб и наше приложение тоже становилось недоступным и не пытаться это как-то обработать. Ну или если всё-таки нужно такие ошибки понимать именно в Vue.js приложении, то, возможно, нужно как-то доработать keycloak-js.

Исходники проекта можно посмотреть тут.

Login Flows

Теперь давайте перейдём непосредственно к настройке процессов авторизации и регистрации, а так же соответствующих страниц. Т.к. возможностей по конфигурации Login Flows в Keycloak очень много, то давайте рассмотрим лишь некоторые из них, которые, на мой взгляд, являются наиболее важными. Предлагаю следующий список.

  • Авторизация и регистрация пользователей

  • Локализация страниц

  • Подтверждение email

  • Вход через социальные сети

Локализация страниц в Keycloak

Запустим наши Spring Boot и Vue.js приложения. При переходе в клиентское Vue.js приложение нас перенаправит на страницу логина Keycloak.

В первую очередь давайте добавим поддержку русского языка. Для этого в админке Keycloak, на вкладке Theams, в настройки realm включаем флаг Internationalization Enabled . В Supported Locales убираем все локали кроме ru, пусть наше приложение на Vue.js поддерживает только один язык. В Default Locale выставляем ru.

Нажимаем Save и возвращаемся в наше клиентское приложение.

Как видим, русский язык у нас появился, правда, не все текстовки были локализованы. Это можно исправить, добавив собственные варианты перевода. Сделать это можно на вкладке Localization, в настройках realm.

Здесь имеется возможность добавить текстовки вручную по одной, либо загрузить их из json файла. Давайте сделаем это вручную. Для начала требуется добавить локаль. Вводим ru и нажимаем Create. После чего попадаем на страницу Add localization text. На этой странице нам необходимо заполнить поля Key и Value. Если с value всё ясно, это будет просто значение нашей текстовки, то вот где взять Key не совсем понятно. В документации допустимые ключи нигде не описаны (либо я просто плохо искал), поэтому остаётся лишь найти их в исходниках Keycloak. Находим в ресурсах нужную нам базовую тему base и страницу login, а затем файл с текстовками в локали en - messages_en.properties. В этом файле по значению определяем нужный нам ключ текстовки, добавляем его в Key на странице Add localization text, а так же добавляем нужное нам Value и нажимаем Save.

После этого на вкладке Localization в настройках realm, при выборе локали ru, появляется таблица, в которой можно посмотреть, отредактировать или удалить нашу добавленную текстовку.

Вернёмся в наше клиентское приложение. Теперь все текстовки на странице логина локализованы.

Регистрация пользователей

Поддержку регистрации пользователей можно добавить, включив флаг User registration на вкладке Login в настройках realm.

После этого на странице логина появится кнопка Регистрация.

Нажимаем на кнопку Регистрация и попадаем на соответствующую страницу.

Давайте немного подкрутим эту страницу. Для начала добавим отсутствующий перевод текстовки, аналогично тому, как мы делали это ранее для страницы логина. Так же давайте уберём поле Имя пользователя. На самом деле совсем его убрать нельзя, т.к. это поля обязательно для заполнения у пользователя Keycloak, но можно сделать так, чтобы в качестве имени пользователя использовался email, при этом поле Имя пользователя исчезнет с формы регистрации. Сделать это можно, включив флаг Email as username на вкладке Login в настройках realm. После этого возвращаемся на страницу регистрации и видим что поле исчезло.

Кроме этого на странице логина поле, которое ранее называлось Имя пользователя или E-mail, теперь называется просто E-mail. Правда, пользователи, которые, например, были созданы до выставления этого флага, и у которых email отличается от имени пользователя, могут продолжать в качестве логина использовать имя пользователя и всё будет корректно работать.

Подтверждение email

Давайте включим подтверждение email у пользователей, чтобы после регистрации они не могли зайти в наше приложение, пока не подтвердят свой email. Сделать это можно, включив флаг Verify email на вкладке Login в настройках realm. И нет, после этого волшебным образом всё не заработает, нужно ещё где-то добавить конфигурацию SMTP-сервера, с которого мы будем осуществлять рассылку. Сделать это можно на вкладке Email, в настройках realm. Ниже приведён пример настроек SMTP-сервера Gmail.

Нажимаем Test connection и получаем ошибку.

Ошибка возникает из-за того, что при нажатии на Test connection должно отправиться письмо на адрес пользователя, под которым мы сейчас залогинены в Keycloak, но этот адрес не задан. Соответственно, если вы заранее задали этот email, ошибки не будет.

Давайте зададим email нашему пользователю Keycloak. Для этого перейдём в realm master на страницу Users и нажмём View all users, чтобы отобразить всех пользователей.

Перейдём на страницу редактирования нашего пользователя и зададим ему email.

Возвращаемся на страницу конфигурации SMTP-сервера, снова пробуем Test connection и видим что всё рабо... Нет, мы снова видим ошибку. Правда, уже другую.

Если все параметры подключения к SMTP-серверу заданы корректно, и вы тоже используете SMTP-сервер Gmail, то, возможно, вам поможет разрешение доступа к вашему аккаунту "ненадежных приложений" в настройках безопасности вашего аккаунта, с которого вы пытаетесь отправлять письма. Если не поможет, то да прибудет с вами Google. Если вы используете SMTP-сервер не от Gmail, то, возможно, у вас не будет подобной ошибки, а если будет, может быть, в настройках вашей почты тоже можно задать подобную конфигурацию для "ненадежных приложений".

Снова жмём Test connection и, наконец-то, получаем Success.

Содержимое письма, которое будет ждать нас на почте, представлено ниже.

Давайте зарегистрируем пользователя, чтобы проверить, что подтверждение email корректно работает.

После нажатия на кнопку Регистрация. Мы попадём на страницу с предупреждением о том, что нужно подтвердить email. На эту страницу мы будем попадать каждый раз при логине в нашем приложении, до тех пор пока не подтвердим email.

На почту нам придёт письмо с ссылкой, по которой можно подтвердить email.

После перехода по ссылке мы попадём на нашу тестовую страницу /pages/Home.vue, на которой просто выводится имя пользователя. Т.к. в настройках нашего realm мы указали Email as username, то на данной странице мы увидим email нашего пользователя.

Social Login

Теперь добавим вход через социальные сети. В качестве примера давайте рассмотрим вход с помощью Google. Для того чтобы добавить данный функционал нужно в нашем realm создать соответствующий Identity Provider. Для этого нужно перейти на страницу Identity Providers и в списке Add provider... выбрать Google.

После этого мы попадём на страницу создания Identity Provider.

Здесь нам требуется задать два обязательных параметра - Client ID и Client Secret. Взять их можно из Google Cloud Platform.

Сказ о получении ключей из Google Cloud Platform

В первую очередь нам нужно создать в Google Cloud Platform проект.

Жмём CREATE PROJECT и попадаем на страницу создания проекта.

Задаём имя, жмём CREATE, ждём некоторое время, пока не будет создан наш проект, и после этого попадаем на DASHBOARD проекта.

Выбираем в меню APIs & Services -> Credentials. И попадаем на страницу на которой мы можем создавать различные ключи для нашего приложения.

Жмём Create credentials -> OAuth client ID и попадаем на очередную страницу.

Видим, что нам так просто не хотят давать возможность создавать ключи, а сначала просят создать некий OAuth consent screen. Что ж, хорошо, жмём CONFIGURE CONSENT SCREEN и снова новая страница.

Здесь давайте выберем External. Ну как выберем, выбора, на самом деле, у нас нет, т.к. Internal доступно только пользователямGoogle Workspace и эта штука платная и нужна, в общем-то, только организациям. Нажимаем Create и попадаем на страницу OAuth consent screen. Здесь заполняем название приложения и почты и жмём SAVE AND CONTINUE.

На следующей странице можно задать так называемые области действия OAuth 2.0 для API Google. Ничего задавать не будем, жмём SAVE AND CONTINUE.

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

На следующей странице можно проверить все данные, которые мы заполняли и в случае чего перейти к конкретному блоку с помощью кнопки EDIT.

Жмём BACK TO DASHBOARD, чтобы всё это уже закончить, и попадаем на страницу, на которой мы можем редактировать все те данные, которые мы вводили на предыдущих страницах.

Жмём Credentials, затем снова Create credentials -> OAuth client ID и попадаем на страницу создания OAuth client ID. И снова нужно что-то вводить. Google, ну сколько можно?! Ниже приведены поля, которые необходимо заполнить на этой странице.

  • Application type - выбираем Web application

  • Name - пишем имя нашего приложения

  • Authorized redirect URIs - сюда пишем значение из поля Redirect URI со страницы создания Identity Provider, чтобы Google редиректил пользователей на корректный адрес Keycloak после авторизации

Жмём CREATE и, наконец-то, получаем требуемые нам Client ID и Client Secret, которые нам нужно указать на странице создания Identity Provider в Keycloak.

Заполняем поля Client ID и Client Secret и жмём Save, чтобы создать Identity Provider. Теперь вернёмся на страницу логина нашего клиентского приложения. На этой странице появится нелокализованная текстовка, добавить её можно аналогично тому, как это было сделано ранее. Ниже на скрине ниже эта проблема уже устранена.

Итак, это всё что требовалось сделать, теперь мы можем входить в наше приложение с помощью Google.

Импорт и экспорт в Keycloak

В Keycloak есть возможность импортировать и экспортировать конфигурации ваших realm'ов. Это можно использовать, например, для переноса конфигураций между различными инстансами Keycloak. Или, что более вероятно, для того чтобы можно было запускать Keycloak локально с уже готовой конфигурацией и использовать его для разработки. Это может быть полезно в тех ситуациях, когда нет возможности запустить Keycloak глобально на каком-нибудь сервере либо когда до этого инстанса Keycloak по какой-либо причине нет доступа.

Для того чтобы экспортировать конфигурацию из Keycloak, нужно перейти на страницу Export, выбрать данные, которые нужно экспортировать и нажать Export.

После этого выгрузится файл realm-export.json с конфигурацией того realm в котором мы сейчас находимся. При этом различные пароли и секреты в этом файле будут в виде **********, поэтому, прежде чем куда-то импортировать этот файл, нужно заменить все такие значения на корректные. Либо сделать это после импорта через адиминку.

Импортировать данные можно на странице Import. Либо в yml-файле Docker Compose, если вы его используете. Для этого нужно указать в переменной окружения KEYCLOAK_IMPORT путь до ранее экспортированного файла и примонтировать этот файл в контейнер с помощью volumes. Итоговый файл приведен ниже.

# For developmentversion: "3.8"services:  keycloak:    image: jboss/keycloak:12.0.2    environment:      KEYCLOAK_USER: admin      KEYCLOAK_PASSWORD: admin      KEYCLOAK_IMPORT: "/tmp/realm-export.json"    volumes:      - "./keycloak/realm-export.json:/tmp/realm-export.json"    ports:      - 8080:8080
Импорт файлов локализации

Как уже упоминалось ранее, файлы локализации можно импортировать через админку. Помимо этого у Keycloak есть Admin REST API, а именно метод POST /{realm}/localization/{locale}, с помощью которого можно это сделать. В теории это можно использовать в Docker Compose, чтобы при запуске сразу загружать все текстовки в автоматическом режиме. На практике для этого можно написать bash-скрипт и вызвать его после того как в контейнере запустится Keycloak. Пример такого скрипта приведен ниже.

#!/bin/bashDIRECT_GRANT_RESPONSE=$(curl -i --request POST http://localhost:8080/auth/realms/master/protocol/openid-connect/token --header "Accept: application/json" --header "Content-Type: application/x-www-form-urlencoded" --data "grant_type=password&username=admin&password=admin&client_id=admin-cli");export DIRECT_GRANT_RESPONSEACCESS_TOKEN=$(echo $DIRECT_GRANT_RESPONSE | grep "access_token" | sed 's/.*\"access_token\":\"\([^\"]*\)\".*/\1/g');export ACCESS_TOKENcurl -i --request POST http://localhost:8080/auth/admin/realms/list-keep/localization/ru -F "file=@ru.json" --header "Content-Type: multipart/form-data" --header "Authorization: Bearer $ACCESS_TOKEN";

И в докер образе jboss/keycloak даже есть возможность запускать скрипты при старте (см. раздел Running custom scripts on startup на странице докер образа). Но запускаются они до фактического старта Keycloak. Поэтому пока я оставил данный вопрос не решенным. Если у кого-то есть идеи как это можно красиво сделать - оставляйте их в комментариях.

Заключение

Что ж. Вот и всё. Это конец. Надеюсь, мне удалось показать насколько просто и быстро можно интегрировать Keycloak с вашими приложениями. А так же насколько просто можно прикручивать различный функционал, связанный с аутентификацией и авторизацией пользователей, благодаря тому что большая часть этого функционала доступна из коробки. По крайней мере, насколько это может быть проще, чем если бы всё это приходилось писать самому.
Надеюсь, вы нашли в этой статье что-то полезное и интересное.
И ещё... Берегите там себя.

Подробнее..

Микросервисная авторизация для чайников для чайников

01.12.2020 12:23:05 | Автор: admin
В данной статье рассматривается пример реализации распределенной микросервисной авторизации доступа для множества пользователей к множеству ресурсов или операций. Уровень подготовки читателя может быть любой, кто знаком с программированием и проектированием. Так же рассматриваются примеры использования на практике и одна из задач реализована в виде небольшой микросервисной системы.

1 Немного теории


Основной задачей авторизации доступа является разграничение доступа к целевому объекту на основании данных о субъекте, запрашивающем доступ, и свойствах объекта.

При проектировании любой системы авторизации следует обращать внимания на характеристики, описываемые ниже.

1.1 Время восстановления/энтропия входных параметров


Авторизация принимает решение о разграничении доступа на основе множества входных параметров. Если таковых будет слишком много, либо мерность множеств окажется слишком большой, то авторизация будет терять слишком много ресурсов на выполнение ненужных операций. Это как сортировать данные пузырьком и qsort. Не следует выполнять ненужные операции.
Если у вашей системы есть на входе A,B,C,D,E,F факты о пользователе, то если вы будете объединять все в одно, то получите A * B * C * D * E * F комбинаций, которые невозможно эффективно кешировать. По возможности следует найти такую комбинацию входных, что бы у вас было A * B * C и D * E * F комбинаций, а еще лучше A * B, C * D, E * F, которые вы уже легко сможете вычислять и кешировать.
Эта характеристика очень важна так же для построения системы правил, которые будут предоставлять пользователям доступы.

1.2 Детализация авторизации


Второй характеристикой является предел детализации авторизации, когда дальнейшее разграничение доступа становится уже невозможным. Например вы можете разграничивать доступ к ручкам микросервиса, но уже не иметь возможности ограничить доступ к сущностям, которые она выдает. Либо доступ к сущности вы ограничиваете, но вот операции доступны все и всем. И т.д.
От того, насколько детально нужно работать авторизации зависит как время восстановления, так и энтропия входных данных, т.к. работать вам придется со все увеличивающимся массивом входных данных. Разумеется, если вы не предпринимаете попытки для того, что бы уровни детализации друг на друга влияли аддитивно, а не мультипликативно.

2 Обобщенная модель данных


Прежде чем проектировать, необходимо договориться о терминах и структурах данных с их взаимосвязями.

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

2.1 Пользователь


Субъект, чей доступ необходимо авторизовать.
Данное определение задает субъекты, которые могут быть как людьми, так и автоматическими системами, которые получают доступ к вашим ресурсам. У любого пользователя могут быть свои роли, разрешения и группы.

2.2 Сервис


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

2.3 Ресурс


Единичный объем информации для работы авторизации, например проект.
В рамках сервиса могут существовать ресурсы, доступ к которым необходимо ограничивать. Это может быть как файл в облачном сервисе, так и заметка, видимая ограниченному кругу лиц.

2.4 Разрешение


Право пользователя выполнять операцию над сервисом и/или ресурсом.

2.5 Роль


Множество разрешений, по сути под словом роль всегда подразумевается множество разрешений.
Следует отметить, что в системе может не быть ролей и напрямую задаваться набор разрешений для каждого пользователя в отдельности. Так же в системе могут отсутствовать разрешения (речь про хранение их в виде записей БД), но быть роли, при этом такие роли будут называться статичными, а если в системе существуют разрешения, тогда роли называются динамическими, так как можно в любой момент поменять, создать или удалить любую роль так, что система все равно продолжит функционировать.
Ролевые модели позволяют как выполнять вертикальное, так и горизонтальное разграничение доступа (описание ниже). Из типа разграничения доступа следует, что сами роли делятся на типы:
  • Глобальные роли применимые ко всем сервисам для вертикального разграничения;
  • Локальные применяются только к ресурсу горизонтальное разграничение.

Но отсюда следует вопрос, если у пользователя есть глобальная роль и локальная, то как определить его эффективные разрешения? Поэтому авторизация должна быть в виде одной из форм:
  • Конъюнктивная
  • Дизъюнктивная

Подробное описание использования типа ролей и формы авторизации ниже.

Роль имеет следующие связи:
  • Пользователь у каждого пользователя может быть множество ролей;
  • Разрешение каждая роль объединяет в себе несколько разрешений всегда вне зависимости от типа ролей.

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

2.6 Группа


Используя группы можно объединить множество ресурсов, в том числе расположенных в разных сервисах в одно целое, и управлять доступом к ним используя одну точку. Это позволяет гораздо проще и быстрее реализовывать предоставление доступа пользователя к большим массивам информации, не требуя избыточных данных в виде записи на каждый доступ к каждому ресурсу по отдельности вручную или автоматически. По сути вы решаете задачу предоставления доступа к N ресурсам создавая 1 запись.
Группы используются исключительно для горизонтального разграничения доступа (описание ниже). Даже если вы будете использовать группы для доступа к сервисам, то это все равно горизонтальное разграничение доступа, потому что сервис это совокупность ресурсов.
Группа имеет следующие связи:
  • Пользователи множество пользователей участвующих в группе, сама связь может быть при этом:
    • Безусловной связь определяется только пользователем и группой, такая связь всегда уникальна;
    • Условной связь определяет роль/разрешение пользователю в группе, например вы можете добавить пользователя с разрешением модерировать ресурсы группы, таких связей может быть сколько угодно.
  • Ресурсы список ресурсов этой группы.


2.7 Вертикальное и горизонтальное разграничение доступа.


Самым простым способом представить себе работу авторизации это нарисовать таблицы на каждого пользователя и каждый сервис, где колонки будут задавать разрешения, а строки ресурсы, на пересечении ставятся галочки там, где доступ разрешен (привет excel-warrior'ам).
Для обеспечения доступа вы можете либо связать пользователя с ролью, что даст вам заполнение соответствующих разрешению ролей колонок в таблицах такое разграничение называется вертикальным. Либо вы можете связать пользователя, роль и ресурс, что ограничит доступ до отдельного ресурса такое предоставление доступ является горизонтальным. Вторым вариантом горизонтального доступа являются группы, когда вы заполняете множество строк соответствующими разрешениями, когда включаете пользователя в эту группу.

2.8 Глобальность/локальность авторизации доступа в микросервисах


Несмотря на то, что все роли могут хранится в единой БД, то вот связи между ними и пользователями хранить все вместе на всю вашу огромную систему это типичный антипаттерн, делать так является грубейшей ошибкой, это все равно что POSIX права на файлы хранить в облачном сервисе, вместе с таблицей inode. Так же вы теряете огромную часть важного функционала, который вам могла бы предоставлять ваша БД сразу, например пагинация ресурсов с разграничением доступа к строкам.
Связи пользователя с глобальными ролями хранить следует в самом сервисе ролей. Связь же пользователя с локальной ролью необходимо хранить там же, где лежат ресурсы соответствующей роли. Например если у вас есть глобальная роль Менеджер проектов, то хранить ее будем в сервисе ролей, а локальная роль Менеджер проекта (окончание у них разное) уже в сервисе проектов, при этом связь будет не (Пользователь, Роль), а (Пользователь, Роль, Проект). Либо в случае групп (Группа, Проект). Под это дело можно будет написать специальный авторизационный клиент, и когда в другом микросервисе, например документов, вам нужно будет авторизовать доступ к проекту, то вы всего лишь повесите 2 аннотации @RequireRole(User) @ProjectAccess. Все остальное уже сделано либо самим спрингом, либо авторизационными клиентами.
При таком подходе вы можете фильтровать видимость данных уже на уровне вашей БД, в сам запрос вам нужно будет отдать массив групп и ролей пользователя, что бы дополнительно отфильтровать данные. Вам ничто не мешает теперь показывать страницу только тех ресурсов, которые пользователь может видеть! Даже если этих ресурсов миллионы.

2.9 Конъюнктивная/дизъюнктивная форма авторизации


Из-за того, что у теперь у нас существуют два варианта наборов разрешений пользователя, то можно выделить два варианта, как объединить все вместе:
  • Конъюнкция требуем, что бы пользователь имел как глобальное разрешение, так и локальное, в этом случае можно закрыть доступ сотруднику сразу после его увольнения, не тратя время и ресурсы на чистку его записей в БД;
  • Дизъюнкция если у пользователя есть глобальное разрешение, то предоставляем доступ ко всем ресурсам, если есть доступ только к конкретному ресурсу, то только к нему.

Одним из важных преимуществ конъюнктивной формы является жесткий контроль за всеми операциями, которые может выполнять пользователь, а именно без глобального разрешения пользователь не сможет получить доступ в следствие ошибки на месте. Так же реализация в виде программного продукта горазо проще, так как достаточно сделать последовательность проверок, каждая из которых либо успешна, либо бросает исключение.

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

Рекомендуется всегда использовать только конъюнктивную форму авторизации в виду ее большей гибкости и возможности снизить вероятность появления инцидентов по утечке данных. Так же именно конъюнктивная форма авторизации имеет большую устойчивость к ДДОС атакам в виду использования меньшего объема ресурсов. Ее проще реализовать и использовать, отлаживать. Разумеется можно и дизъюнктивную форму натравить на анализ аннотаций метода и поиск соответствующих сервисов, но запросы вы отправлять будете скорее всего синхронно, один за другим, если же будете делать асинхронные вызовы, то много ресурсов будет уходить в пустоту, оно вам надо?

2.10 О кешировании данных


Основной проблемой при работе авторизации является точка в которой необходимо кешировать результаты, что бы работало быстрее все, да еще желательно так, что бы нагрузка на ЦПУ не была высокой. Вы можете поместить все ваши данные в память и спокойно их читать, но это дорогое решение и не все готовы будут себе его позволить, особенно если требуется всего лишь пара ТБ ОЗУ. Второй крайностью является попытка кешировать входные параметры и результат (или запрос и ответ), в результате опять же потребуется пара ТБ ОЗУ, но уже для хранения всех ваших вариантов.
Правильным решением было бы найти такие места, которые бы позволяли с минимальными затратами по памяти давать максимум быстродействия.
Предлагаю решить такую задачу на примере ролей пользователя и некоего микросервиса, которому нужно проверять наличие роли у пользователя. Разумеется в данном случае можно сделать карту (Пользователь, Роль) -> Boolean. Проблема в том, что все равно придется на каждую пару делать запрос на удаленный сервис. Даже если вам данные будут предоставлять за 0,1 мс, то ваш код будет работать все равно медленно. Очевидным решением будет кешировать сразу роли пользователя! В итоге у нас будет кеш Пользователь -> Роль[]. При таком подходе на какое-то время микросервис сможет обрабатывать запросы от пользователя без необходимости нагружать другие микросервисы. Разумеется читатель спросит, а что если ролей у пользователя десятки тысяч? Ваш микросервис всегда работает с ограниченным количеством ролей, которые он проверяет, соответственно вы всегда можете либо захардкодить список, либо найти все аннотации, собрать все используемые роли и фильтровать только их.
Полагаю, что ход мыслей, которому стоит следовать, стал понятен.

2.11 Выводы по обобщенной модели


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

3 Как использовать обобщенную модель данных


Теперь, определившись с терминами, можно разобрать несколько задачек, для того, что бы видеть как на практике применить материал.

3.1 Закрытый онлайн аукцион


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

3.2 Логистика


Огромная логистическая компания требует разграничить доступы для супервизоров и продавцов да еще с учетом региона. Работа такова, что продавец может выставить заявку на получение товара, а супервизор уже решает кому и сколько достанется. Соответственно продавец может видеть свой магазин и все товары в нем, а так же созданные им заявки, статус их обработки. Супервизор может видеть все магазины региона, в котором он работает, их заявки и историю исполнения заявок, так же он может перевести заявку в состояние исполнения для непосредственно доставки в магазин нужного товара.

Для реализации такого функционала нам потребуется реестр регионов и магазинов в качестве отдельного микросервиса, назовем его С1. Заявки и историю будем хранить на С2. Авторизация А.
Далее при обращении продавца для получения списка его магазинов (а у него может быть их несколько), С1 вернет только те, в которых у него есть меппинг (Пользователь, Магазин), так как ни в какие регионы он не добавлен и для продавца регионы всегда пустое множество. Разумеется при условии, что у пользователя есть разрешение просматривать список магазинов посредством микросервиса А.
При получении заявок магазина, проверка в А разрешений смотреть заявки, а затем С2 отравляет запрос в С1 и получает разрешение или запрет на доступ к магазину.

Работа супервизора будет выглядеть немного иначе, вместо регистрации супервизора на каждый магазин региона, мы сделаем меппинги (Пользователь, Регион) и (Регион, Магазин) в этом случае для супервизора у нас всегда будет список актуальных магазинов с которыми он работает.
В таком случае, когда супервизор решит перевести заявку на поставку в исполнение, то после проверки, что у него есть разрешение в А, С2 отправит проверку, что у супервизора есть доступ к магазину.

3.3 Секретные части документа


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

Когда пользователь обращается в сервис для получения блоков документа, микросервис в авторизации проверяет роль пользователя на чтение документов, далее в БД каждый блок имеет флаг секрет. У документа есть меппинг (Пользователь, Документ) список пользователей, которые подписали соглашение. В этом случае при получении из БД блоков документа можно сразу отфильтровать невидимые для пользователя блоки одним запросом.

3.4 Команда и ее проекты


Если у вас большая команда, и много проектов, и такая команда у вас не одна, то координировать работу их будет той еще задачей. Особенно если таких команд у вас тысячи. Каждому разработчику вы доступы не пропишите, а если у вас человек еще и в нескольких командах работает, то разграничивать доступ станет еще сложнее. Например если 2 команды работают над одним проектом, и разработчик в обоих командах присутствует, из одной его пнули, как теперь определять актуальный состав его проектов?

Такую проблему проще всего решить при помощи групп.
Группа будет объединять все необходимые проекты в одно целое. При добавлении участника в команду, добавляем меппинг (Пользователь, Группа, Роль).

Допустим, что Вася разработчик и ему нужно вызвать метод develop на микросервисе для выполнения своих обязанностей. Этот метод потребует у Васи роли в проекте Разработчик.
Как мы уже договаривались регистрация пользователя в каждый проект для нас недопустима. Поэтому микросервис проектов обратится в микросервис групп, что бы получить список групп, в которых Вася Разработчик. Получив список этих групп можно легко проверить уже в БД микросервиса проектов есть ли у проекта какая-нибудь из полученных групп и на основании этого предоставлять или запрещать ему доступ.

4 Проектируем микросервисы для работы чайников



4.1 Словесное описание решаемой задачи


Компания ОАО ПьемЧай ежедневно требует работы от миллиона сотрудников и сотни тысяч гостей. Все сотрудники пьют чай и в этом состоит их работа. В компании есть гости, которые пить чай не могут, для них доступны опции, которые позволяют им видеть какой чай они смогут пить на своей будущей работе, а так же лучших сотрудников года, месяца и недели. Некоторые сотрудники в пандемию работают удаленно и доступ к чаю им предоставляет курьерская служба Кракен, прямо к точке доставки. Удаленные сотрудники могут получать чай только из той точки доставки, к которой они привязаны. Находясь в офисе они все равно могут пить чай как обычные сотрудники.

4.2 Архитектура решения


Для решения поставленной задачи нам необходимы следующие микросервисы:
  1. Ролевая модель для глобальной авторизации запросов
  2. Чайный сервис для предоставления возможности пить чай и получать статистику по выпитому
  3. Кракен гейт с проверкой доступа к точке, все остальные проверки совершит чайный сервис

Графически это будет выглядеть так:


В качестве БД будем использовать PostgreSQL.
Предусмотрим следующие обязательные для решения задачи роли:
  1. Сотрудник для возможности пить чай;
  2. Удаленный сотрудник для доступа к микросервису кракена (так как сотрудники в офисе не должны иметь возможности пить чай через точки распрастранения чая);
  3. Кракен учетная запись микросервиса, что бы была возможность обращаться к API чайного сервиса;
  4. Авторизационная учетная запись для предоставления доступа микросервисов к ролевой модели.


5 Реализация микросервисов



Для реализации воспользуемся Spring фреймворком, он довольно медленный, но зато на нем легко и быстро можно реализовывать приложения. Так как за перформансом мы не гонимся, то попробуем на нем достичь скромных 1к рпс авторизованных пустых запросов (хотя люди на спринге умудрялись 100к пустых запросов проворачивать [1]).

Весь проект поделен на несколько репозиториев:


Как работать с проектами
У всех проектов есть общая зависимость ее нужно скачать и выполнить mvn clean install.
Далее нужно собрать докер образы каждого микросервиса, что выполяется командой sh buildDocker.sh автоматически выполнит mvn clean install и сборку образа докера.

В каждом проекте есть кофигурация docker-compose с минимальной для каждого сервиса конфигурацией, лучшим вариантом запуска будет sh start.sh. Остановить сервисы можно при помощи docker-compose down, если этого не сделать, то выполнить скрипт из папок других сервисов вы не сможете.

В скрипте инициализации БД ролевой модели выставлено немного пользователей 10к всего лишь, рекомендую для тестов увеличить до 500к хотя бы. Инициализация займет примерно 100 секунд.


Как устроены test suites
Создаете конфигурацию запуска с мейн классом org.lastrix.perf.tester.PerfTester, в аргумент задаете имя класса test suite, через vm аргументы задаете параметры -Dperf.tester.warmup.round.time=PT1S -Dperf.tester.test.round.time=PT60S -Dperf.tester.test.threads.max=32 -Dperf.tester.test.threads.min=4 -Dperf.tester.test.round.count=1

Для начала разберемся с базой данных ролевой модели:


Пользователи задаются другим сервисом, который в нашем случае даже не реализован, так же как аутентификация. Сделано для упрощения примера.
Обратите внимание на поле is_deleted, оно необходимо, так же как и индекс на (user_id,role_id,is_deleted), иначе работать ничего не будет удаление записей из больших таблиц приведет к снижению производительности (а у нас расчет на 5 млн строк). Если у вас окажется слишком много записей с флагом is_deleted=true (например 1 млн строк), то гораздо выгоднее будет в техническое окно запустить скрипт на удаление таких записей разом, чем удалять по запросу.

Для работы с таблицами нам потребуется сделать REST API, причем одно для пользователей людей, второе для пользователей сервисов.

Само апи довольно легко в понимании, но оно работает медленно (в районе 4к-7к рпс из-за БД), причем как получение ролей пользователя, так и проверка наличия ролей. Для ускорения работы необходимо сделать кеширование, поэтому добавляем в конфигурацию mafp.role.cache.enable. Кеш можно сделать в виде карты, но лучше использовать уже готовое решение com.google.common.cache.Cache и сэкономить себе кучу нервов, потому что через HashMap или WeakHashMap сделать быстро не удастся.
И в данный момент у нас появляется вопрос, а что именно нужно кешировать? В данном случае лучшим вариантом будет сами роли, вернее их имена на каждого пользователя, так мы сэкономим запросы к БД.
В абстрактном классе заведем кеш под эти данные, но есть одно но. Если хранить Set, то нам придется столкнуться с тем, что поиск в случае большого количества ролей у пользователя будет работать медленно (в JVM строки никогда не работали быстро и не будут). Так же проблемой будет то, что строки, которые мы получим из хибернейта не будут интернированными или каким-то образом преобразованными до синглтонов в рамках нашего кеша, т.е. a == a всегда для нас будет ложно, и соответственно будет сравнение строк, а нужно указателей или простых значений, например целых чисел. Это помимо того, что у нас память будет забиваться одними и теми же строками.
Поэтому необходимо добавить карту, в которой будем хранить меппинг (Строка, Целое). И в кеше хранить уже не набор имен ролей пользователя, а набор идентификаторов ролей пользователя, причем эти числа могут не совпадать с идентификаторами в вашей БД. Поиск Integer в HashSet выполняется намного быстрее, чем строки, при этом у вас де факто все строки будут интернированными, что в нашем случае является обязательным шагом иначе потребление памяти будет расти неконтролируемым образом.

Получив абстрактный класс, можно реализовать его для самого ролевого сервиса, что бы авторизовать доступы к нему, а так же для клиента, что бы проще было пользоваться в других сервисах. Обратите внимание, что на клиенте мы кешируем не все роли, а только те, что зарегистрированы в белом списке, это позволит нам на каждом сервисе при желании хранить хоть 100к строк кеша, потратив чуть больше 10 мб хипа.

Если инициализировать БД 500к пользователями, 10к ролей и каждому пользователю выдать до 10 ролей, то можно провести нагрузочное тестирование. Результат на выданных 4 ядрах от моего i7-9700k получится скоромный среднее 12992, минимум 12832, максимум 13088 rps. Результат получен на 16 потоках клиента.

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

Идеальным тестом для авторизации является отношение количества пустых запросов за единицу времени к количеству авторизованных пустых запросов за единицу времени для N (желаемое число под в вашей системе, требующих авторизацию, хотя вы можете вычислять это значение от 1 до N и показывать в виде графика). При этом записывать будем в виде Ка1 коэффициент авторизации 1, если количество под равно 1. Чем ближе к 1 это значение, тем лучше работает ваша авторизация как слой. Именно поэтому полученные выше 13к рпс не имеют никакого значения, даже если вы сделаете авторизацию, которая выдает 10e100 rps, она будет бесполезна, если коэффициент авторизации окажется скажем 1000, потому что плохим результатом является уже 100 т.е. ваша авторизация в 100 раз замедляет ничего не делающее приложение.
В данной статье будет измеряться только Ка1, потому что железа нет.

Приступим к чайному сервису. Для тестирования авторизации у нас создан в нем ендпоинт POST /api/v1/tea/dummy.
Если отключить авторизацию (/noauth в конец добавить), то тестирование покажет нам на выданные 2 ядра чайному сервису и 2 ролевому среднее 18208, минимум 18048, максимум 18560 на 32 потоках клиента это количество запросов, которое может обработать наша система с заданными настройками, исключая какую либо бизнес логику, быстрее работать у нас уже ничего не будет. Включив же авторизацию, которая заключается в проверке у пользователя роли User, получим следующие показания на 6 потоках клиента среднее 1062, минимальное 1062, максимальное 1068.
При этом тестировалось на 100к случайно выбираемых пользователях! Ка1 = 17.1.

И напоследок попьем чай (делаем инсерты в БД) в 6 потоков клиента на POST /api/v1/tea получим среднее 1086, минимум 1086, максимум 1086 (а должно быть ~4к). О чем это говорит нам? О том что авторизация является для нас боттлнеком, который замедляет работу системы (из-за сильной нагрузки на ЦПУ, что говорит о том, что алгоритм кеширования у нас не очень). Еще о том, что погрешности измерений огромные.
Коэффициент авторизации позволит вам не только оценить работу алгоритмов авторизации, но и очертить границы в которых может работать весь ваш комплекс микросервисов, а так же сообщить вам имеет ли смысл оптимизировать код конкретного микросервиса или нет, потому что может так оказаться, что выигрыш будет 0.
Если же всего лишь включить роль в JWT токен, то с 12 потоками клиента можно получить от dummy среднее 6396, минимум 6372, максимум 6444. Т.е. Ка1 оказался равен 2.8!
А что будет, если отключить кеширование на клиенте, не использовать роли JWT и оставить его только на ролевой модели? Производительность вырастет, потому что сетевые задержки около нулевые и нагрузка на ЦПУ для dummy станет минимальной среднее 3817, минимум 3806, максимум 3828, что говорит нам о Ка1 = 4.7. Однако следует учитывать, что такое решение не будет масштабируемым, потому что каждый клиент будет обращаться постоянно к сервису ролевой модели и увеличивать на него нагрузку, уже при сотне под начнутся серьезные проблемы, делающие невозможной работу авторизации, разве что у вас на каждый сервис, которому нужна авторизация будет пода авторизации, которая эту функцию будет выполнять, есть в интернете такие сетевые инженеры, которые любую проблему готовы залить водой решить добавив под.

А выпустить кракена так и не удалось, потому что железо не выдержит испытаний. Может быть в другой раз, когда будет бюджет недельку-другую погонять кластер на сотню машинок по 2-4 ядра с парой гиг памяти.

Заключение


В статье предложена универсальная модель для авторизации доступа множеству пользователей к множеству ресурсов с минимальными потерями по производительности, позволяющая делать эффективное кеширование и хранение промежуточных данных на микросервисах, для сборки результата авторизации just-in-time. Предложена метрика (в виде коэффициента), для оценки работы авторизации микросервисов с учетом требований работы всего комплекса и показано, как он может меняться в зависимости от подхода, а именно наличия/отсутствия кеша, хранения ролей в JWT токене и т.д. Не показано, что этот коэффициент всегда растет с увеличением количества под в системе.
Предложенная модель данных позволяет разделить данные между частями микросервисной архитектуры, тем самым снизив требования к железу, дает возможность работать только с теми данными, которые нужны в конкретный момент времени конкретному сервису, не пытаться искать в огромном массиве все подряд или пытаться искать данные в полностью обобщенном хранилище.
Показано, что при малом количестве микросервисов нет смысла проводить кеширование на клиенте авторизации, так как это приведет к большей нагрузке на ЦПУ. Предоставлены данные, которые позволяют сделать вывод, что необходимо балансировать ресурсы в зависимости от размеров системы, если она слишком мала, то есть смысл нагружать больше сеть, но при увеличении и разрастании выгоднее добавить пару ядер в микросервисы, для обеспечения работы кешей и разгрузки сети, микросервисов авторизационных данных. Вопрос больше в том, что для вас дешевле, найти дополнительные ядра или сеть улучшать с гигабитной до десятигигабитной, особенно если можно сделать авторизацию, когда достаточно для ее работы 100 мбит сети.

Литература


  1. High-Concurrency HTTP Clients on the JVM
Подробнее..

Авторизация из приложения C на портале BlaBlaCar.ru

13.12.2020 16:23:06 | Автор: admin

Зачем ?

Из кода Вашего приложения Вы можете авторизоваться на любом web-портале. Это может быть нужно когда Вы хотите получить доступ к ресурсам Вашего аккаунта.

Что можно получить на данном портале ?

Вы можете получить список Ваших поездок, а можете проверить какие поездки запланированы на всем портале от пункта "A" в пункт "B" на конкретную дату. Вы можете больше.

Что понадобится ?

  • Локальный прокси-сервер, рекомендую Fiddler - просто и удобно

  • Visual Studio

Соберем данные

Работа web-клиента и web-сервера происходит по протоколу HTTP. Клиент отправляет HTTP запросы в виде заголовков, сервер отправляет ответ в виде заголовков. Это значит что для нашей задачи нужно отправить на сервер несколько запросов чтобы авторизоваться и получить необходимые данные. Чтобы узнать список запросов мы воспользуемся программой Fiddler. После запуска Fiddler откроем любой браузер, перейдем на портал blablacar.ru, авторизуемся и получим список необходимых HTTP-запросов (лишние запросы удалены).

Перейдем к коду. Авторизация.

Для выполнения HTTP-запросов используется класс HttpWebRequest. Воспользуемся несложными обертками для этого класса: классы GetRequest и PostRequest.

GetRequest
    public class GetRequest    {        private HttpWebRequest _request;        public void Run(ref CookieContainer cookies)        {            _request = (HttpWebRequest)WebRequest.Create(Address);            _request.Headers.Add("DNT", "1");            _request.Method = "Get";            _request.Accept = Accept;            _request.Host = Host;            if (TurnOffProxy) _request.Proxy = null;            else _request.Proxy = Proxy;            if (UseUnsafeHeaderParsing)            {                var config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);                var settings = (SettingsSection)config.GetSection("system.net/settings");                settings.HttpWebRequest.UseUnsafeHeaderParsing = true;                config.Save(ConfigurationSaveMode.Modified);                ConfigurationManager.RefreshSection("system.net/settings");            }            _request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;            if (!ContentType.IsEmpty()) _request.ContentType = ContentType;            if (TimeOut > 0)            {                _request.Timeout = TimeOut;                _request.ReadWriteTimeout = TimeOut;            }            else            {                _request.Timeout = 35000;                _request.ReadWriteTimeout = 35000;            }            if (NoCachePolicy == false)            {                var noCachePolicy = new HttpRequestCachePolicy(HttpRequestCacheLevel.NoCacheNoStore);                _request.CachePolicy = noCachePolicy;            }            foreach (KeyValuePair<string, string> keyValuePair in Headers)            {                _request.Headers.Add(keyValuePair.Key, keyValuePair.Value);            }            if (UserAgent == null) _request.UserAgent = Data.Ie11;            else _request.UserAgent = UserAgent;            if (AllowAutoRedirect != null)                _request.AllowAutoRedirect = (bool)AllowAutoRedirect;            if (KeepAlive != null)                _request.KeepAlive = (bool)KeepAlive;            if (Expect100Continue != null)                _request.ServicePoint.Expect100Continue = (bool)Expect100Continue;            if (!Referer.IsEmpty())                _request.Referer = Referer;            _request.CookieContainer = cookies;            try            {                HttpWebResponse response = (HttpWebResponse)_request.GetResponse();                if ((response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Moved || response.StatusCode == HttpStatusCode.Redirect) && response.ContentType.StartsWith("image", StringComparison.OrdinalIgnoreCase))                {                    // if the remote file was found, download oit                    using (Stream inputStream = response.GetResponseStream())                    {                        byte[] buffer = new byte[64000];                        int bytesRead;                        bytesRead = inputStream.Read(buffer, 0, buffer.Length);                        Response = Convert.ToBase64String(buffer, 0, bytesRead);                    }                }                else                {                    var stream = response.GetResponseStream();                    if (stream != null) Response = new StreamReader(stream).ReadToEnd();                    ResponseHeaders = response.Headers;                    RequestHeaders = _request.Headers;                }                response.Close();            }            catch (WebException ex)            {                using (var stream = ex.Response.GetResponseStream())                using (var reader = new StreamReader(stream))                {                    Response = reader.ReadToEnd();                }            }            catch (Exception ex)            {            }        }        Dictionary<string, string> Headers = new Dictionary<string, string>();        public void AddHeader(string headerName, string headerValue)        {            Headers[headerName] = headerValue;        }        public bool NoCachePolicy { get; set; }        public bool AcceptGZipEncoding { get; set; }        public bool UseUnsafeHeaderParsing { get; set; }        public string Address { get; set; }        public string Accept { get; set; }        public string Referer { get; set; }        public string Host { get; set; }        public bool? KeepAlive { get; set; }        public string ContentType { get; set; }        public bool? Expect100Continue { get; set; }        public string Response { get; private set; }        public bool? AllowAutoRedirect { get; set; }        public WebHeaderCollection ResponseHeaders { get; private set; }        public WebHeaderCollection RequestHeaders { get; private set; }        public string UserAgent { get; set; }        public WebProxy Proxy { get; set; }        public bool TurnOffProxy { get; set; }        public int TimeOut { get; set; }    }
PostRequest
    public class PostRequest    {        private HttpWebRequest _request;        public void Run(ref CookieContainer cookies)        {            _request = (HttpWebRequest)WebRequest.Create(Address);            _request.Method = "POST";            _request.Host = Host;            _request.Headers.Add("DNT", "1");            _request.Proxy = Proxy;            _request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;            if (TimeOut > 0)            {                _request.Timeout = TimeOut;                _request.ReadWriteTimeout = TimeOut;            }            else            {                _request.Timeout = 90000;                _request.ReadWriteTimeout = 90000;            }            var noCachePolicy = new HttpRequestCachePolicy(HttpRequestCacheLevel.NoCacheNoStore);            _request.CachePolicy = noCachePolicy;            if (Expect100Continue == true) _request.ServicePoint.Expect100Continue = true;            else _request.ServicePoint.Expect100Continue = false;            _request.ContentType = ContentType;            _request.Accept = Accept;            _request.Referer = Referer;            _request.KeepAlive = KeepAlive;            foreach (KeyValuePair<string, string> keyValuePair in Headers)            {                _request.Headers.Add(keyValuePair.Key, keyValuePair.Value);            }            _request.CookieContainer = cookies;            if (UserAgent == null) _request.UserAgent = Dom.Catalog.Data.Ie11;            else _request.UserAgent = UserAgent;            if (AllowAutoRedirect != null)                _request.AllowAutoRedirect = (bool)AllowAutoRedirect;            byte[] sentData;            if (ByteData != null) sentData = ByteData;            else sentData = Encoding.UTF8.GetBytes(Data);            _request.ContentLength = sentData.Length;            Stream sendStream = _request.GetRequestStream();            sendStream.Write(sentData, 0, sentData.Length);            sendStream.Close();            WebResponse response = _request.GetResponse();            ResponseHeaders = response.Headers;            RequestHeaders = _request.Headers;            Stream stream = response.GetResponseStream();            if (stream != null)            {                if (ResponseEncoding.IsEmpty()) Response = new StreamReader(stream).ReadToEnd();                else Response = new StreamReader(stream, Encoding.GetEncoding(ResponseEncoding)).ReadToEnd();            }            response.Close();        }        Dictionary<string, string> Headers = new Dictionary<string, string>();        public void AddHeader(string headerName, string headerValue)        {            Headers[headerName] = headerValue;        }        public bool NoCachePolicy { get; set; }        public string Response { get; set; }        public string ResponseEncoding { get; set; }        public string Data { get; set; }        public byte[] ByteData { get; set; }        public string Address { get; set; }        public string Accept { get; set; }        public string Host { get; set; }        public string ContentType { get; set; }        public string Referer { get; set; }        public bool KeepAlive { get; set; }        public bool? Expect100Continue { get; set; }        public string UserAgent { get; set; }        public bool? AllowAutoRedirect { get; set; }        public WebHeaderCollection ResponseHeaders { get; private set; }        public WebHeaderCollection RequestHeaders { get; private set; }        public WebProxy Proxy { get; set; }        public int TimeOut { get; set; }    }

В переменные user и password необходимо внести значения Вашего аккаунта.

В cookies у нас будут храниться все cookie выполненных запросов.

proxy - это адрес Вашего прокси. Например, "127.0.0.1:8888" - стандартный адрес запущенного Fiddler.

SecurityProtocolType.Tls12 - указание используемого протокола безопасности.

var user = "user";              // !!! rewrite value from your account !!!var password = "password";      // !!! rewrite value from your account !!!// We keep cookies herevar cookies = new CookieContainer();// Any proxy, for example Fiddlervar proxy = new WebProxy("127.0.0.1:8888");ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;

Для выполнения авторизации на портале необходимо получить cookie и некоторые переменные, которые хранятся в результате запроса "https://www.blablacar.ru/".

var getRequest = new GetRequest(){     Address = "https://www.blablacar.ru/",     Accept = "text/html, application/xhtml+xml, image/jxr, */*",     Host = "www.blablacar.ru",     KeepAlive = true,     UserAgent = "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko",     Proxy = proxy};getRequest.Run(ref cookies);

Используя нехитрые методы работы с текстом, находим значение переменной "visitorId":

var visitorStart = getRequest.Response.IndexOf("visitorId") + 12;var visitorEnd = getRequest.Response.IndexOf("\"", visitorStart);var visitorId = getRequest.Response.Substring(visitorStart, visitorEnd - visitorStart);

В переменную xCorrelationId необходимо передать случайный GUID.

Теперь все значения готовы чтобы выполнить авторизацию на портале.

// Random valuevar xCorrelationId = Guid.NewGuid();// Auth requestvar data = $"{{\"login\":\"{user}\",\"password\":\"{password}\",\"rememberMe\":true,\"grant_type\":\"password\"}}";var postRequest = new PostRequest(){    Data = data,    Address = $"https://auth.blablacar.ru/secure-token",    Accept = "application/json",    Host = "auth.blablacar.ru",    ContentType = "application/json",    Referer = "https://www.blablacar.ru/login/email",    KeepAlive = true,    Proxy = proxy};postRequest.AddHeader("x-client", "SPA|1.0.0");postRequest.AddHeader("x-correlation-id", xCorrelationId.ToString());postRequest.AddHeader("x-currency", "RUB");postRequest.AddHeader("x-forwarded-proto", "https");postRequest.AddHeader("x-locale", "ru_RU");postRequest.AddHeader("x-visitor-id", visitorId);postRequest.AddHeader("Origin", "https://www.blablacar.ru");postRequest.Run(ref cookies);

В ответе на POST-запрос авторизации мы должны получить значение еще одной переменной, назовем ее "bearerCookieValue". Это значение хранится в заголовках "Set-Cookie" ответа.

var bearerCookieValue = string.Empty;var headers = postRequest.ResponseHeaders;for (int i = 0; i < headers.Count; ++i){    string header = headers.GetKey(i);    if (header == "Set-Cookie")    {         var headerValue = headers.GetValues(i)[0];         headerValue = WebUtility.UrlDecode(headerValue);         var accessTokenStart = postRequest.Response.IndexOf("access_token") + 15;         var accessTokenEnd = postRequest.Response.IndexOf("\"", accessTokenStart);         bearerCookieValue = postRequest.Response.Substring(accessTokenStart, accessTokenEnd - accessTokenStart);    }}

Список выполненных поездок

Теперь все готово чтобы выполнять запросы за необходимой информацией. Весь код сформирован на основании просмотренных заголовков запроса/ответа в прокси-сервере Fiddler.

getRequest = new GetRequest(){    Address = "https://edge.blablacar.ru/bookings-and-tripoffers?active=false",    Accept = "application/json",    Host = "edge.blablacar.ru",    KeepAlive = true,    ContentType = "application/json",    Referer = "https://www.blablacar.ru/rides/history",    Proxy = proxy};getRequest.AddHeader("x-blablacar-accept-endpoint-version", "2");getRequest.AddHeader("x-client", "SPA|1.0.0");getRequest.AddHeader("x-correlation-id", xCorrelationId.ToString());getRequest.AddHeader("x-currency", "RUB");getRequest.AddHeader("x-forwarded-proto", "https");getRequest.AddHeader("x-locale", "ru_RU");getRequest.AddHeader("x-visitor-id", visitorId);getRequest.AddHeader("authorization", $"Bearer {bearerCookieValue}");getRequest.AddHeader("Origin", "https://www.blablacar.ru");getRequest.Run(ref cookies);

Ответ получаем в виде JSON-формата:

Находим список всех поездок на портале

В данном примере найдем список всех поездок из Москвы в Санкт-Петербург на 15 декабря 2020 года.

var date = "2020-12-15";var searchUid = Guid.NewGuid();getRequest = new GetRequest(){    Address = $"https://edge.blablacar.ru/trip/search?from_coordinates=55.755826%2C37.617299&from_country=RU&to_coordinates=59.931058%2C30.360909&to_country=RU&departure_date={date}&min_departure_time=00%3A00%3A00&requested_seats=1&passenger_gender=UNKNOWN&search_uuid={searchUid}",    Accept = "application/json",    Host = "edge.blablacar.ru",    KeepAlive = true,    ContentType = "application/json",    Referer = "https://www.blablacar.ru/",    Proxy = proxy};getRequest.AddHeader("x-blablacar-accept-endpoint-version", "2");getRequest.AddHeader("x-client", "SPA|1.0.0");getRequest.AddHeader("x-correlation-id", xCorrelationId.ToString());getRequest.AddHeader("x-currency", "RUB");getRequest.AddHeader("x-forwarded-proto", "https");getRequest.AddHeader("x-locale", "ru_RU");getRequest.AddHeader("x-visitor-id", visitorId);getRequest.AddHeader("authorization", $"Bearer {bearerCookieValue}");getRequest.AddHeader("Origin", "https://www.blablacar.ru");getRequest.Run(ref cookies);

В ответе на запрос получаем JSON-строку с поездками на портале:

Готово

Подробнее..
Категории: C , Net , Авторизация , Blablacar , Fiddler

Spring Security пример REST-сервиса с авторизацией по протоколу OAuth2 через BitBucket и JWT

17.11.2020 00:09:32 | Автор: admin
В предыдущей статье мы разработали простое защищенное веб приложение, в котором для аутентификации пользователей использовался протокол OAuth2 с Bitbucket в качестве сервера авторизации. Кому-то такая связка может показаться странной, но представьте, что мы разрабатываем CI (Continuous Integration) сервер и хотели бы иметь доступ к ресурсам пользователя в системе контроля версий. Например, по такому же принципу работает довольно известная CI платформа drone.io.

В предыдущем примере для авторизации запросов к серверу использовалась HTTP-сессия (и куки). Однако для реализации REST-сервиса данный способ авторизации не подходит, поскольку одним из требований REST архитектуры является отсутсвие состояния. В данной статье мы реализуем REST-сервис, авторизация запросов к которому будет осуществляться с помощью токена доступа (access token).

Немного теории


Аутентификация это процесс проверки учётных данных пользователя (логин/пароль). Проверка подлинности пользователя осуществляется путём сравнения введённого им логина/пароля с сохраненными данными.
Авторизация это проверка прав пользователя на доступ к определенным ресурсам. Авторизация выполняется непосредственно при обращении пользователя к ресурсу.

Рассмотрим порядок работы двух вышеупомянутых способов авторизации запросов.
Авторизация запросов с помощью HTTP-сессии:
  • Пользователь проходит аутентификацию любым из способов.
  • На сервере создается HTTP-сессия и куки JSESSIONID, хранящий идентификатор сессии.
  • Куки JSESSIONID передается на клиент и сохраняется в браузере.
  • С каждым последующим запросом на сервер отправляется куки JSESSIONID.
  • Сервер находит соответствующую HTTP-сессию с информацией о текущем пользователе и определяет имеет ли пользователь права на выполнение данного вызова.
  • Для выполнения выхода из приложения необходимо удалить с сервера HTTP-сессию.


Авторизация запросов с помощью токена доступа:
  • Пользователь проходит аутентификацию любым из способов.
  • Сервер создает токен доступа, подписанный секретным ключом, а затем отправляет его клиенту. Токен содержит идентификатор пользователя и его роли.
  • Токен сохраняется на клиенте и передается на сервер с каждым последующим запросом. Как правило для передачи токена используетя HTTP заголовок Authorization.
  • Сервер сверяет подпись токена, извлекает из него идентификатор пользователя, его роли и определяет имеет ли пользователь права на выполнение данного вызова.
  • Для выполнения выхода из приложения достаточно просто удалить токен на клиенте без необходимости взаимодействия с сервером.


Распространенным форматом токена доступа в настоящее время является JSON Web Token (JWT). Токен в формате JWT содержит три блока, разделенных точками: заголовок (header), набор полей (payload) и сигнатуру (подпись). Первые два блока представлены в JSON-формате и закодированы в формат base64. Набор полей может состоять как из зарезервированных имен (iss, iat, exp), так и произвольных пар имя/значение. Подпись может генерироваться как при помощи симметричных, так и асимметричных алгоритмов шифрования.

Реализация


Мы реализуем REST-сервис, предоставляющий следующее API:
  • GET /auth/login запустить процесс аутентификации пользователя.
  • POST /auth/token запросить новую пару access/refresh токенов.
  • GET /api/repositories получить список Bitbucket репозиториев текущего пользователя.


Высокоуровневая архитектура приложения.

Заметим, что поскольку приложение состоит из трех взаимодействующих компонентов, помимо того, что мы выполняем авторизацию запросов клиента к серверу, Bitbucket авторизует запросы сервера к нему. Мы не будем настраивать авторизацию методов по ролям, чтобы не делать пример сложнее. У нас есть только один API метод GET /api/repositories, вызывать который могут только аутентифицированные пользователи. Сервер может выполнять на Bitbucket любые операции, разрешенные при регистрации OAuth клиента.

Процесс регистрации OAuth клиента описан в предыдущей статье.

Для реализации мы будем использовать Spring Boot версии 2.2.2.RELEASE и Spring Security версии 5.2.1.RELEASE.

Переопределим AuthenticationEntryPoint.


В стандартном веб-приложении, когда осуществляется обращение к защищенному ресурсу и в секьюрити контексте отсутствует объект Authentication, Spring Security перенаправляет пользователя на страницу аутентификации. Однако для REST-сервиса более подходящим поведением в этом случае было бы возвращать HTTP статус 401 (UNAUTHORIZED).

RestAuthenticationEntryPoint
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {    @Override    public void commence(            HttpServletRequest request,            HttpServletResponse response,            AuthenticationException authException) throws IOException {        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());    }}


Создадим login endpoint.


Для аутентификации пользователя мы по-прежнему используем OAuth2 с типом авторизации Authorization Code. Однако на предыдущем шаге мы заменили стандартный AuthenticationEntryPoint своей реализацией, поэтому нам нужен явный способ запустить процесс аутентификации. При отправке GET запроса по адресу /auth/login мы перенаправим пользователя на страницу аутентификации Bitbucket. Параметром этого метода будет URL обратного вызова, по которому мы возвратим токен доступа после успешной аутентификации.

Login endpoint
@Path("/auth")public class AuthEndpoint extends EndpointBase {...    @GET    @Path("/login")    public Response authorize(@QueryParam(REDIRECT_URI) String redirectUri) {        String authUri = "/oauth2/authorization/bitbucket";        UriComponentsBuilder builder = fromPath(authUri).queryParam(REDIRECT_URI, redirectUri);        return handle(() -> temporaryRedirect(builder.build().toUri()).build());    }}


Переопределим AuthenticationSuccessHandler.


AuthenticationSuccessHandler вызывается после успешной аутентификации. Сгенерируем тут токен доступа, refresh токен и выполним редирект по адресу обратного вызова, который был передан в начале процесса аутентификации. Токен доступа вернем параметром GET запроса, а refresh токен в httpOnly куке. Что такое refresh токен разберем позже.

ExampleAuthenticationSuccessHandler
public class ExampleAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {    private final TokenService tokenService;    private final AuthProperties authProperties;    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    public ExampleAuthenticationSuccessHandler(            TokenService tokenService,            AuthProperties authProperties,            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.tokenService = requireNonNull(tokenService);        this.authProperties = requireNonNull(authProperties);        this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository);    }    @Override    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {        log.info("Logged in user {}", authentication.getPrincipal());        super.onAuthenticationSuccess(request, response, authentication);    }    @Override    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {        Optional<String> redirectUri = getCookie(request, REDIRECT_URI).map(Cookie::getValue);        if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {            throw new BadRequestException("Received unauthorized redirect URI.");        }        return UriComponentsBuilder.fromUriString(redirectUri.orElse(getDefaultTargetUrl()))                .queryParam("token", tokenService.newAccessToken(toUserContext(authentication)))                .build().toUriString();    }    @Override    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {        redirectToTargetUrl(request, response, authentication);    }    private boolean isAuthorizedRedirectUri(String uri) {        URI clientRedirectUri = URI.create(uri);        return authProperties.getAuthorizedRedirectUris()                .stream()                .anyMatch(authorizedRedirectUri -> {                    // Only validate host and port. Let the clients use different paths if they want to.                    URI authorizedURI = URI.create(authorizedRedirectUri);                    return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())                            && authorizedURI.getPort() == clientRedirectUri.getPort();                });    }    private TokenService.UserContext toUserContext(Authentication authentication) {        ExampleOAuth2User principal = (ExampleOAuth2User) authentication.getPrincipal();        return TokenService.UserContext.builder()                .login(principal.getName())                .name(principal.getFullName())                .build();    }    private void addRefreshTokenCookie(HttpServletResponse response, Authentication authentication) {        RefreshToken token = tokenService.newRefreshToken(toUserContext(authentication));        addCookie(response, REFRESH_TOKEN, token.getId(), (int) token.getValiditySeconds());    }    private void redirectToTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {        String targetUrl = determineTargetUrl(request, response, authentication);        if (response.isCommitted()) {            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);            return;        }        addRefreshTokenCookie(response, authentication);        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);        getRedirectStrategy().sendRedirect(request, response, targetUrl);    }}


Переопределим AuthenticationFailureHandler.


В случае, если пользователь не прошел аутентификацию, мы перенаправим его по адресу обратного вызова, который был передан в начале процесса аутентификации с параметром error, содержащим текст ошибки.

ExampleAuthenticationFailureHandler
public class ExampleAuthenticationFailureHandler implements AuthenticationFailureHandler {    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    public ExampleAuthenticationFailureHandler(            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository);    }    @Override    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {        String targetUrl = getFailureUrl(request, exception);        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);        redirectStrategy.sendRedirect(request, response, targetUrl);    }    private String getFailureUrl(HttpServletRequest request, AuthenticationException exception) {        String targetUrl = getCookie(request, Cookies.REDIRECT_URI)                .map(Cookie::getValue)                .orElse(("/"));        return UriComponentsBuilder.fromUriString(targetUrl)                .queryParam("error", exception.getLocalizedMessage())                .build().toUriString();    }}


Создадим TokenAuthenticationFilter.


Задача этого фильтра извлечь токен доступа из заголовка Authorization в случае его наличия, провалидировать его и инициализировать секьюрити контекст.

TokenAuthenticationFilter
public class TokenAuthenticationFilter extends OncePerRequestFilter {    private final UserService userService;    private final TokenService tokenService;    public TokenAuthenticationFilter(            UserService userService, TokenService tokenService) {        this.userService = requireNonNull(userService);        this.tokenService = requireNonNull(tokenService);    }    @Override    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException {        try {            Optional<String> jwtOpt = getJwtFromRequest(request);            if (jwtOpt.isPresent()) {                String jwt = jwtOpt.get();                if (isNotEmpty(jwt) && tokenService.isValidAccessToken(jwt)) {                    String login = tokenService.getUsername(jwt);                    Optional<User> userOpt = userService.findByLogin(login);                    if (userOpt.isPresent()) {                        User user = userOpt.get();                        ExampleOAuth2User oAuth2User = new ExampleOAuth2User(user);                        OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(oAuth2User, oAuth2User.getAuthorities(), oAuth2User.getProvider());                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));                        SecurityContextHolder.getContext().setAuthentication(authentication);                    }                }            }        } catch (Exception e) {            logger.error("Could not set user authentication in security context", e);        }        chain.doFilter(request, response);    }    private Optional<String> getJwtFromRequest(HttpServletRequest request) {        String token = request.getHeader(AUTHORIZATION);        if (isNotEmpty(token) && token.startsWith("Bearer ")) {            token = token.substring(7);        }        return Optional.ofNullable(token);    }}


Создадим refresh token endpoint.


В целях безопасности время жизни токена доступа обычно делают небольшим. Тогда в случае его кражи злоумышленник не сможет пользоваться им бесконечно долго. Чтобы не заставлять пользователя выполнять вход в приложение снова и снова используется refresh токен. Он выдается сервером после успешной аутентификации вместе с токеном доступа и имеет большее время жизни. Используя его можно запросить новую пару токенов. Refresh токен рекомендуют хранить в httpOnly куке.

Refresh token endpoint
@Path("/auth")public class AuthEndpoint extends EndpointBase {...    @POST    @Path("/token")    @Produces(APPLICATION_JSON)    public Response refreshToken(@CookieParam(REFRESH_TOKEN) String refreshToken) {        return handle(() -> {            if (refreshToken == null) {                throw new InvalidTokenException("Refresh token was not provided.");            }            RefreshToken oldRefreshToken = tokenService.findRefreshToken(refreshToken);            if (oldRefreshToken == null || !tokenService.isValidRefreshToken(oldRefreshToken)) {                throw new InvalidTokenException("Refresh token is not valid or expired.");            }            Map<String, String> result = new HashMap<>();            result.put("token", tokenService.newAccessToken(of(oldRefreshToken.getUser())));            RefreshToken newRefreshToken = newRefreshTokenFor(oldRefreshToken.getUser());            return Response.ok(result).cookie(createRefreshTokenCookie(newRefreshToken)).build();        });    }}


Переопределим AuthorizationRequestRepository.


Spring Security использует объект AuthorizationRequestRepository для хранения объектов OAuth2AuthorizationRequest на время процесса аутентификации. Реализацией по умолчанию является класс HttpSessionOAuth2AuthorizationRequestRepository, который использует HTTP-сессию в качестве хранилища. Т.к. наш сервис не должен хранить состояние, эта реализация нам не подходит. Реализуем свой класс, который будет использовать HTTP cookies.

HttpCookieOAuth2AuthorizationRequestRepository
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {    private static final int COOKIE_EXPIRE_SECONDS = 180;    private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "OAUTH2-AUTH-REQUEST";    @Override    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {        return getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)                .map(cookie -> deserialize(cookie, OAuth2AuthorizationRequest.class))                .orElse(null);    }    @Override    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {        if (authorizationRequest == null) {            removeAuthorizationRequestCookies(request, response);            return;        }        addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);        String redirectUriAfterLogin = request.getParameter(QueryParams.REDIRECT_URI);        if (isNotBlank(redirectUriAfterLogin)) {            addCookie(response, REDIRECT_URI, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);        }    }    @Override    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {        return loadAuthorizationRequest(request);    }    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {        deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);        deleteCookie(request, response, REDIRECT_URI);    }    private static String serialize(Object object) {        return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));    }    @SuppressWarnings("SameParameterValue")    private static <T> T deserialize(Cookie cookie, Class<T> clazz) {        return clazz.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));    }}


Настроим Spring Security.


Соберем все проделанное выше вместе и настроим Spring Security.

WebSecurityConfig
@Configuration@EnableWebSecuritypublic static class WebSecurityConfig extends WebSecurityConfigurerAdapter {    private final ExampleOAuth2UserService userService;    private final TokenAuthenticationFilter tokenAuthenticationFilter;    private final AuthenticationFailureHandler authenticationFailureHandler;    private final AuthenticationSuccessHandler authenticationSuccessHandler;    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    @Autowired    public WebSecurityConfig(            ExampleOAuth2UserService userService,            TokenAuthenticationFilter tokenAuthenticationFilter,            AuthenticationFailureHandler authenticationFailureHandler,            AuthenticationSuccessHandler authenticationSuccessHandler,            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.userService = userService;        this.tokenAuthenticationFilter = tokenAuthenticationFilter;        this.authenticationFailureHandler = authenticationFailureHandler;        this.authenticationSuccessHandler = authenticationSuccessHandler;        this.authorizationRequestRepository = authorizationRequestRepository;    }    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .cors().and()                .csrf().disable()                .formLogin().disable()                .httpBasic().disable()                .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))                .exceptionHandling(eh -> eh                        .authenticationEntryPoint(new RestAuthenticationEntryPoint())                )                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/auth/**").permitAll()                        .anyRequest().authenticated()                )                .oauth2Login(oauth2Login -> oauth2Login                        .failureHandler(authenticationFailureHandler)                        .successHandler(authenticationSuccessHandler)                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService))                        .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(authorizationRequestRepository))                );        http.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);    }}


Создадим repositories endpoint.


То ради чего и нужна была аутентификация через OAuth2 и Bitbucket возможность использовать Bitbucket API для доступа к своим ресурсам. Используем Bitbucket repositories API для получения списка репозиториев текущего пользователя.

Repositories endpoint
@Path("/api")public class ApiEndpoint extends EndpointBase {    @Autowired    private BitbucketService bitbucketService;    @GET    @Path("/repositories")    @Produces(APPLICATION_JSON)    public List<Repository> getRepositories() {        return handle(bitbucketService::getRepositories);    }}public class BitbucketServiceImpl implements BitbucketService {    private static final String BASE_URL = "https://api.bitbucket.org";    private final Supplier<RestTemplate> restTemplate;    public BitbucketServiceImpl(Supplier<RestTemplate> restTemplate) {        this.restTemplate = restTemplate;    }    @Override    public List<Repository> getRepositories() {        UriComponentsBuilder uriBuilder = fromHttpUrl(format("%s/2.0/repositories", BASE_URL));        uriBuilder.queryParam("role", "member");        ResponseEntity<BitbucketRepositoriesResponse> response = restTemplate.get().exchange(                uriBuilder.toUriString(),                HttpMethod.GET,                new HttpEntity<>(new HttpHeadersBuilder()                        .acceptJson()                        .build()),                BitbucketRepositoriesResponse.class);        BitbucketRepositoriesResponse body = response.getBody();        return body == null ? emptyList() : extractRepositories(body);    }    private List<Repository> extractRepositories(BitbucketRepositoriesResponse response) {        return response.getValues() == null                ? emptyList()                : response.getValues().stream().map(BitbucketServiceImpl.this::convertRepository).collect(toList());    }    private Repository convertRepository(BitbucketRepository bbRepo) {        Repository repo = new Repository();        repo.setId(bbRepo.getUuid());        repo.setFullName(bbRepo.getFullName());        return repo;    }}


Тестирование


Для тестирования нам понадобится небольшой HTTP-сервер, на который будет отправлен токен доступа. Сначала попробуем вызвать repositories endpoint без токена доступа и убедимся, что в этом случае получим ошибку 401. Затем пройдем аутентификацию. Для этого запустим сервер и перейдем в браузере по адресу http://localhost:8080/auth/login. После того как мы введем логин/пароль, клиент получит токен и вызовет repositories endpoint еще раз. Затем будет запрошен новый токен и снова вызван repositories endpoint с новым токеном.

OAuth2JwtExampleClient
public class OAuth2JwtExampleClient {    /**     * Start client, then navigate to http://localhost:8080/auth/login.     */    public static void main(String[] args) throws Exception {        AuthCallbackHandler authEndpoint = new AuthCallbackHandler(8081);        authEndpoint.start(SOCKET_READ_TIMEOUT, true);        HttpResponse response = getRepositories(null);        assert (response.getStatusLine().getStatusCode() == SC_UNAUTHORIZED);        Tokens tokens = authEndpoint.getTokens();        System.out.println("Received tokens: " + tokens);        response = getRepositories(tokens.getAccessToken());        assert (response.getStatusLine().getStatusCode() == SC_OK);        System.out.println("Repositories: " + IOUtils.toString(response.getEntity().getContent(), UTF_8));        // emulate token usage - wait for some time until iat and exp attributes get updated        // otherwise we will receive the same token        Thread.sleep(5000);        tokens = refreshToken(tokens.getRefreshToken());        System.out.println("Refreshed tokens: " + tokens);        // use refreshed token        response = getRepositories(tokens.getAccessToken());        assert (response.getStatusLine().getStatusCode() == SC_OK);    }    private static Tokens refreshToken(String refreshToken) throws IOException {        BasicClientCookie cookie = new BasicClientCookie(REFRESH_TOKEN, refreshToken);        cookie.setPath("/");        cookie.setDomain("localhost");        BasicCookieStore cookieStore = new BasicCookieStore();        cookieStore.addCookie(cookie);        HttpPost request = new HttpPost("http://localhost:8080/auth/token");        request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType());        HttpClient httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build();        HttpResponse execute = httpClient.execute(request);        Gson gson = new Gson();        Type type = new TypeToken<Map<String, String>>() {        }.getType();        Map<String, String> response = gson.fromJson(IOUtils.toString(execute.getEntity().getContent(), UTF_8), type);        Cookie refreshTokenCookie = cookieStore.getCookies().stream()                .filter(c -> REFRESH_TOKEN.equals(c.getName()))                .findAny()                .orElseThrow(() -> new IOException("Refresh token cookie not found."));        return Tokens.of(response.get("token"), refreshTokenCookie.getValue());    }    private static HttpResponse getRepositories(String accessToken) throws IOException {        HttpClient httpClient = HttpClientBuilder.create().build();        HttpGet request = new HttpGet("http://localhost:8080/api/repositories");        request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType());        if (accessToken != null) {            request.setHeader(AUTHORIZATION, "Bearer " + accessToken);        }        return httpClient.execute(request);    }}


Консольный вывод клиента.
Received tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDMxLCJleHAiOjE2MDU0NjY2MzF9.UuRYMdIxzc8ZFEI2z8fAgLz-LG_gDxaim25pMh9jNrDFK6YkEaDqDO8Huoav5JUB0bJyf1lTB0nNPaLLpOj4hw, refreshToken=BBF6dboG8tB4XozHqmZE5anXMHeNUncTVD8CLv2hkaU2KsfyqitlJpgkV4HrQqPk)Repositories: [{"id":"{c7bb4165-92f1-4621-9039-bb1b6a74488e}","fullName":"test-namespace/test-repository1"},{"id":"{aa149604-c136-41e1-b7bd-3088fb73f1b2}","fullName":"test-namespace/test-repository2"}]Refreshed tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDM2LCJleHAiOjE2MDU0NjY2MzZ9.oR2A_9k4fB7qpzxvV5QKY1eU_8aZMYEom-ngc4Kuc5omeGPWyclfqmiyQTpJW_cHOcXbY9S065AE_GKXFMbh_Q, refreshToken=mdc5sgmtiwLD1uryubd2WZNjNzSmc5UGo6JyyzsiYsBgOpeaY3yw3T3l8IKauKYQ)

Исходный код


Полный исходный код рассмотренного приложения находится на Github.

Ссылки



P.S.
Созданный нами REST-сервис работает по протоколу HTTP, чтобы не усложнять пример. Но поскольку токены у нас никак не шифруются, рекомендуется перейти на защищенный канал (HTTPS).
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru