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

Oauth2

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 с вашими приложениями. А так же насколько просто можно прикручивать различный функционал, связанный с аутентификацией и авторизацией пользователей, благодаря тому что большая часть этого функционала доступна из коробки. По крайней мере, насколько это может быть проще, чем если бы всё это приходилось писать самому.
Надеюсь, вы нашли в этой статье что-то полезное и интересное.
И ещё... Берегите там себя.

Подробнее..

Настройка Gmail API для замены расширения PHP IMAP и работы по протоколу OAuth2

25.08.2020 12:13:42 | Автор: admin
Оказавшись одним из счастливчиков, совершенно не готовым к тому, что с 15 февраля 2021 года авторизация в Gmail и других продуктах будет работать только через OAuth, я прочитал статью "Google хоронит расширение PHP IMAP" и загрустил начал предпринимать действия по замене расширения PHP IMAP в своём проекте на API Google. Вопросов было больше, чем ответов, поэтому заодно нацарапал мануал.

У меня PHP IMAP используется для следующих задач:
  1. Удаление старых писем из почтовых ящиков. К сожалению, в панели управления корпоративным аккаунтом G Suite можно настроить только срок удаления писем из всех почтовых ящиков организации через N дней после получения. Мне же требуется удалять письма только в заданных почтовых ящиках и через заданное разное количество дней после получения.
  2. Фильтрация, разбор и маркировка писем. С нашего сайта в автоматическом режиме отправляется множество писем, некоторые из которых не доходят до адресатов, о чём, соответственно, приходят отчёты. Нужно эти отчёты отлавливать, разбирать, находить клиента по email и формировать человекочитаемое письмо для менеджера, чтобы тот связался с клиентом и уточнил актуальность адреса электронной почты.


Эти две задачи мы и будем решать при помощи API Gmail в данной статье (а заодно и отключим в настройках почтовых ящиков доступ для небезопасных приложений, который был включён для работы PHP IMAP, и, собственно, перестанет работать в страшный день в феврале 2021). Использовать будем так называемый сервисный аккаунт приложения Gmail, который при соответствующей настройке даёт возможность подключения ко всем почтовым ящикам организации и выполнения в них любых действий.

1. Создаём проект в консоли разработчика Google API


При помощи этого проекта мы и будем осуществлять API-взаимодействие с Gmail, и в нём же создадим тот самый сервисный аккаунт.
Для создания проекта:
  1. Переходим в консоль разработчика Google API и логинимся по администратором G Suite (ну или кто у Вас там пользователь со всеми правами)
  2. Ищем кнопку Создать проект.
    Я нашёл здесь:
    image

    И затем здесь:
    image


    Заполняем имя проекта и сохраняем
    Создание проекта
    image

  3. Переходим к проекту и нажимаем кнопку Включить API и сервисы
    Включить API и сервисы
    image

    Выбираем Gmail API


2. Создаём и настраиваем сервисный аккаунт


Для этого можно воспользоваться официальным мануалом или продолжить чтение:
  1. Переходим в наш добавленный Gmail API, нажимаем кнопку Создать учётные данные и выбираем Сервисный аккаунт
    Создание сервисного аккаунта
    image


    Что-нибудь заполняем и нажимаем Создать
    Сведения о сервисном аккаунте
    image


    Всё остальное можно не заполнять
    Права доступа для сервисного аккаунта
    image

    Предоставление пользователям доступа к сервисному аккаунту
    image

  2. Далее, сервисному аккаунту нужно дать права на чтение или управление почтовыми ящиками. Для этого переходим в консоль администрирования G Suite, открываем главное меню и переходим в пункт Безопасность Управление API.
    Управление API
    image
    image

  3. Прокручиваем страницу вниз и выбираем пункт Настроить делегирование доступа к данным в домене
    Делегирование доступа к данным в домене
    image

    Нажимаем Добавить, в поле Идентификатор клиента копируем соответствующую строку из карточки сервисного аккаунта, а поле Области действия OAuth вставляем права одно или несколько из следующих значений через запятую:

    - https://mail.google.com/ - для полного доступа
    - https://www.googleapis.com/auth/gmail.modify - для редактирования меток
    - https://www.googleapis.com/auth/gmail.readonly - для чтения
    - https://www.googleapis.com/auth/gmail.metadata - для доступа к метаданным


    Сведения о сервисном аккаунте
    image
    image

  4. Возвращаемся к карточке сервисного аккаунта и включаем ещё одну разрешающую галку Включить делегирование доступа к данным в домене G Suite:
    Статус сервисного аккаунта
    image

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

    Для этого со страницы Учётные данные Вашего проекта переходим по ссылке Управление сервисными аккаунтами
    Учётные данные
    image


    и выбираем Действия Создать ключ, тип: JSON
    Управление сервисными аккаунтами
    image


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

На этом настройка API Gmail закончена, далее будет немного моего кака-кода, собственно, реализующего функции, которые до сих пор решались расширением IMAP PHP.

3. Пишем код


По API Gmail есть вполне себе неплохая официальная документация (клик и клик), которой я и пользовался. Но раз уж взялся написать подробный мануал, то приложу и свой собственный кака-код.

Итак, первым делом устанавливаем Google Client Library (apiclient) при помощи composer:

composer require google/apiclient

(Сначала я, как истинный буквоед, установил именно версию 2.0 api-клиента, как указано в PHP Quickstart, но при первом же запуске на PHP 7.4 посыпались всякие ворнинги и алармы, поэтому Вам так же делать не советую)

Затем на основе примеров из официальной документации пишем свой класс для работы с Gmail, не забывая указать файл ключа сервисного аккаунта:
Класс для работы с Gmail
<?php// Класс для работы с Gmailclass GmailAPI{    private $credentials_file = __DIR__ . '/../Gmail/credentials.json'; // Ключ сервисного аккаунта    // ---------------------------------------------------------------------------------------------    /**     * Функция возвращает Google_Service_Gmail Authorized Gmail API instance     *     * @param  string $strEmail Почта пользователя     * @return Google_Service_Gmail Authorized Gmail API instance     * @throws Exception     */    function getService(string $strEmail){        // Подключаемся к почтовому ящику        try{            $client = new Google_Client();            $client->setAuthConfig($this->credentials_file);            $client->setApplicationName('My Super Project');            $client->setScopes(Google_Service_Gmail::MAIL_GOOGLE_COM);            $client->setSubject($strEmail);            $service = new Google_Service_Gmail($client);        }catch (Exception $e) {            throw new \Exception('Исключение в функции getService: '.$e->getMessage());        }        return $service;    }    // ---------------------------------------------------------------------------------------------    /**     * Функция возвращает массив ID сообщений в ящике пользователя     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  array $arrOptionalParams любые дополнительные параметры для выборки писем     * Из них мы сделаем стандартную строку поиска в Gmail вида after: 2020/08/20 in:inbox label:     * и запишем её в переменную q массива $opt_param     * @return array Массив ID писем или массив ошибок array('arrErrors' => $arrErrors), если они есть     * @throws Exception     */    function listMessageIDs(Google_Service_Gmail $service, string $strEmail, array $arrOptionalParams = array()) {        $arrIDs = array(); // Массив ID писем        $pageToken = NULL; // Токен страницы в почтовом ящике        $messages = array(); // Массив писем в ящике        // Параметры выборки        $opt_param = array();        // Если параметры выборки есть, делаем из них строку поиска в Gmail и записываем её в переменную q        if (count($arrOptionalParams)) $opt_param['q'] = str_replace('=', ':', http_build_query($arrOptionalParams, null, ' '));        // Получаем массив писем, соответствующих условию выборки, со всех страниц почтового ящика        do {            try {                if ($pageToken) {                    $opt_param['pageToken'] = $pageToken;                }                $messagesResponse = $service->users_messages->listUsersMessages($strEmail, $opt_param);                if ($messagesResponse->getMessages()) {                    $messages = array_merge($messages, $messagesResponse->getMessages());                    $pageToken = $messagesResponse->getNextPageToken();                }            } catch (Exception $e) {                throw new \Exception('Исключение в функции listMessageIDs: '.$e->getMessage());            }        } while ($pageToken);        // Получаем массив ID этих писем        if (count($messages)) {            foreach ($messages as $message) {                $arrIDs[] = $message->getId();            }        }        return $arrIDs;    }    // ---------------------------------------------------------------------------------------------    /**     * Удаляем сообщения из массива их ID функцией batchDelete     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  array $arrIDs массив ID писем для удаления из функции listMessageIDs     * @throws Exception     */    function deleteMessages(Google_Service_Gmail $service, string $strEmail, array $arrIDs){        // Разбиваем массив на части по 1000 элементов, так как столько поддерживает метод batchDelete        $arrParts = array_chunk($arrIDs, 999);        if (count($arrParts)){            foreach ($arrParts as $arrPartIDs){                try{                    // Получаем объект запроса удаляемых писем                    $objBatchDeleteMessages = new Google_Service_Gmail_BatchDeleteMessagesRequest();                    // Назначаем удаляемые письма                    $objBatchDeleteMessages->setIds($arrPartIDs);                    // Удаляем их                    $service->users_messages->batchDelete($strEmail,$objBatchDeleteMessages);                }catch (Exception $e) {                    throw new \Exception('Исключение в функции deleteMessages: '.$e->getMessage());                }            }        }    }    // ---------------------------------------------------------------------------------------------    /**     * Получаем содержиме сообщения функцией get     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  string $strMessageID ID письма     * @param  string $strFormat The format to return the message in.     * Acceptable values are:     * "full": Returns the full email message data with body content parsed in the payload field; the raw field is not used. (default)     * "metadata": Returns only email message ID, labels, and email headers.     * "minimal": Returns only email message ID and labels; does not return the email headers, body, or payload.     * "raw": Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used.     * @param  array $arrMetadataHeaders When given and format is METADATA, only include headers specified.     * @return  object Message     * @throws Exception     */    function getMessage(Google_Service_Gmail $service, string $strEmail, string $strMessageID, string $strFormat = 'full', array $arrMetadataHeaders = array()){        $arrOptionalParams = array(            'format' => $strFormat // Формат, в котором возвращаем письмо        );        // Если формат - metadata, перечисляем только нужные нам заголовки        if (($strFormat == 'metadata') and count($arrMetadataHeaders))            $arrOptionalParams['metadataHeaders'] = implode(',',$arrMetadataHeaders);        try{            $objMessage = $service->users_messages->get($strEmail, $strMessageID,$arrOptionalParams);            return $objMessage;        }catch (Exception $e) {            throw new \Exception('Исключение в функции getMessage: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------    /**     * Выводим список меток, имеющихся в почтовом ящике     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @return  object $objLabels - объект - список меток     * @throws Exception     */    function listLabels(Google_Service_Gmail $service, string $strEmail){        try{            $objLabels = $service->users_labels->listUsersLabels($strEmail);            return $objLabels;        }catch (Exception $e) {            throw new \Exception('Исключение в функции listLabels: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------    /**     * Добавляем или удаляем метку (флаг) к письму     *     * @param  Google_Service_Gmail $service Authorized Gmail API instance.     * @param  string $strEmail Почта пользователя     * @param  string $strMessageID ID письма     * @param  array $arrAddLabelIds Массив ID меток, которые мы добавляем к письму     * @param  array $arrRemoveLabelIds Массив ID меток, которые мы удаляем в письме     * @return  object Message - текущее письмо     * @throws Exception     */    function modifyLabels(Google_Service_Gmail $service, string $strEmail, string $strMessageID, array $arrAddLabelIds = array(), array $arrRemoveLabelIds = array()){        try{            $objPostBody = new Google_Service_Gmail_ModifyMessageRequest();            $objPostBody->setAddLabelIds($arrAddLabelIds);            $objPostBody->setRemoveLabelIds($arrRemoveLabelIds);            $objMessage = $service->users_messages->modify($strEmail,$strMessageID,$objPostBody);            return $objMessage;        }catch (Exception $e) {            throw new \Exception('Исключение в функции modifyLabels: '.$e->getMessage());        }    }    // ---------------------------------------------------------------------------------------------}


При любом взаимодействии с Gmail первым делом мы вызываем функцию getService($strEmail) класса GmailAPI, которая возвращает авторизованный объект для работы с почтовым ящиком $strEmail. Далее этот объект уже передаётся в любую другую функцию для уже непосредственно выполнения нужных нам действий. Все остальные функции в классе GmailAPI уже выполняют конкретные задачи:

  • listMessageIDs находит письма по заданным критериям и возвращает их ID (передаваемая в функцию listUsersMessages Gmail API строка поиска писем должна быть аналогична строке поиска в веб-интерфейсе почтового ящика),
  • deleteMessages удаляет письма с переданными в неё ID (функция batchDelete API Gmail удаляет не более 1000 писем за один проход, поэтому пришлось разбить массив переданных в функцию ID на несколько массивов по 999 писем и выполнить удаление несколько раз),
  • getMessage получает всю информацию о сообщении с переданным в неё ID,
  • listLabels возвращает список флагов в почтовом ящике (я использовал её, чтобы получить ID флага, который изначально был создан в веб-интерфейсе ящика, и присваивается нужным сообщениям)
  • modifyLabels добавляет или удаляет флаги к сообщению


Далее, у нас есть задача удаления старых писем в различных почтовых ящиках. При этом старыми мы считаем письма, полученные своё количество дней назад для каждого почтового ящика. Для реализации этой задачи пишем следующий скрипт, ежедневно запускаемый cron'ом:
Удаление старых писем
<?php/** * Удаляем письма в почтовых ящиках Gmail * Используем сервисный аккаунт и его ключ */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент// Задаём количества дней хранения почты в ящиках$arrMailBoxesForClean = array(    'a@domain.com' => 30,    'b@domain.com' => 30,    'c@domain.com' => 7,    'd@domain.com' => 7,    'e@domain.com' => 7,    'f@domain.com' => 1);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail// Проходим по списку почтовых ящиков, из которых нужно удалить старые письмаforeach ($arrMailBoxesForClean as $strEmail => $intDays) {    try{        // Подключаемся к почтовому ящику        $service = $objGmailAPI->getService($strEmail);        // Указываем условие выборки писем в почтовом ящике        $arrParams = array('before' => date('Y/m/d', (time() - 60 * 60 * 24 * $intDays)));        // Получаем массив писем, подходящих для удаления        $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail,$arrParams);        // Удаляем письма по их ID в массиве $arrIDs        if (count($arrIDs)) $objGmailAPI->deleteMessages($service,$strEmail,$arrIDs);        // Удаляем все использованные переменные        unset($service,$arrIDs);    }catch (Exception $e) {        $arrErrors[] = $e->getMessage();    }}if (count($arrErrors)){    $strTo = 'my_email@domain.com';    $strSubj = 'Ошибка при удалении старых писем из почтовых ящиков';    $strMessage = 'При удалении старых писем из почтовых ящиков возникли следующие ошибки:'.        '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'.        '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL);    $objMailSender = new mailSender();    $objMailSender->sendMail($strTo,$strSubj,$strMessage);}


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

Задача формирования отчётов для менеджера о недоставленных письмах на основании автоматических отчётов решается следующим скриптом:
Фильтрация и маркировка писем
<?php/* * Подключаемся к ящику a@domain.com * Берём с него письма о том, что наши письма не доставлены: отправитель: mailer-daemon@googlemail.com * Проверяем почтовые ящики в этих письмах. Если они есть у клиентов на нашем сайте, отправляем на b@domain.com * письмо об этом */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент$strEmail = 'a@domain.com';$strLabelID = 'Label_2399611988534712153'; // Флаг reportProcessed - устанавливаем при обработке письма// Параметры выборки$arrParams = array(    'from' => 'mailer-daemon@googlemail.com', // Письма об ошибках приходят с этого адреса    'in' => 'inbox', // Во входящих    'after' => date('Y/m/d', (time() - 60 * 60 * 24)), // За последние сутки    'has' => 'nouserlabels' // Без флага);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail$arrClientEmails = array(); // Массив адресов электронной почты, на которые не удалось отправить сообщениеtry{    // Подключаемся к почтовому ящику    $service = $objGmailAPI->getService($strEmail);    // Находим в нём отчёты за последние сутки о том, что письма не доставлены    $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail, $arrParams);    // Для найденных писем получаем заголовок 'X-Failed-Recipients', в котором содержится адрес, на который пыталось быть отправлено письмо    if (count($arrIDs)){        foreach ($arrIDs as $strMessageID){            // Получаем метаданные письма            $objMessage = $objGmailAPI->getMessage($service,$strEmail,$strMessageID,'metadata',array('X-Failed-Recipients'));            // Заголовки письма            $arrHeaders = $objMessage->getPayload()->getHeaders();            // Находим нужный            foreach ($arrHeaders as $objMessagePartHeader){                if ($objMessagePartHeader->getName() == 'X-Failed-Recipients'){                    $strClientEmail = mb_strtolower(trim($objMessagePartHeader->getValue()), 'UTF-8');                    if (!empty($strClientEmail)) {                        if (!in_array($strClientEmail, $arrClientEmails)) $arrClientEmails[] = $strClientEmail;                    }                    // Помечаем письмо флагом reportProcessed, чтобы не выбирать его в следующий раз                    $objGmailAPI->modifyLabels($service,$strEmail,$strMessageID,array($strLabelID));                }            }        }    }    unset($service,$arrIDs,$strMessageID);}catch (Exception $e) {    $arrErrors[] = $e->getMessage();}// Если найдены адреса электронной почты, на которые не удалось доставить сообщения, проверяем их в базеif (count($arrClientEmails)) {    $objClients = new clients();    // Получаем все email всех клиентов    $arrAllClientsEmails = $objClients->getAllEmails();    foreach ($arrClientEmails as $strClientEmail){        $arrUsages = array();        foreach ($arrAllClientsEmails as $arrRow){            if (strpos($arrRow['email'], $strClientEmail) !== false) {                $arrUsages[] = 'как основной email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }            if (strpos($arrRow['email2'], $strClientEmail) !== false) {                $arrUsages[] = 'как дополнительный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }            if (strpos($arrRow['site_user_settings_contact_email'], $strClientEmail) !== false) {                $arrUsages[] = 'как контактный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"';            }        }        $intUsagesCnt = count($arrUsages);        if ($intUsagesCnt > 0){            $strMessage = 'Не удалось доставить письмо с сайта по адресу электронной почты <span style="color: #000099;">'.$strClientEmail.'</span><br/>                Этот адрес используется';            if ($intUsagesCnt == 1){                $strMessage .= ' '.$arrUsages[0].'<br/>';            }else{                $strMessage .= ':<ul>';                foreach ($arrUsages as $strUsage){                    $strMessage .= '<li>'.$strUsage.'</li>';                }                $strMessage .= '</ul>';            }            $strMessage .= '<br/>Пожалуйста, уточните у клиента актуальность этого адреса электронной почты.<br/><br/>                Это письмо было отправлено автоматически, не отвечайте на него';            if (empty($objMailSender)) $objMailSender = new mailSender();            $objMailSender->sendMail('b@domain.com','Проверьте email клиента',$strMessage);        }    }}if (count($arrErrors)){    $strTo = 'my_email@domain.com';    $strSubj = 'Ошибка при обработке отчётов о недоставленных письмах';    $strMessage = 'При обработке отчётов о недоставленных письмах возникли следующие ошибки:'.        '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'.        '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL);    if (empty($objMailSender)) $objMailSender = new mailSender();    $objMailSender->sendMail($strTo,$strSubj,$strMessage);}


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

Исходники доступны на GitHub.

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

Есть ли корпоративная жизнь на удаленке и как ее обеспечить интеграция внутренней системы аутентификации

30.04.2021 12:16:20 | Автор: admin

В digital-агентстве Convergent, где я работаю, в потоке множество проектов, и у каждого из них может быть собственная админка. Есть несколько окружений (дев, стейдж, лайв). А ещё есть разные внутрикорпоративные сервисы (как собственной разработки, так и сторонние вроде Redmine или Mattermost), которыми ежедневно пользуются сотрудники.

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

В данной статье я хочу поделиться опытом создания собственной внутренней системы аутентификации на основе OpenResty, а также спецификации OAuth2. В качестве основного языка программирования мы используем PHP, а фреймворк Yii 2.

Суммирую необходимый функционал:

  • Единое место управления всеми доступами. Здесь происходит выдача и отзыв доступов в административные панели сайтов и списки доменов;

  • По умолчанию весь доступ запрещён, если не указано обратное (доступ к любым доменам контролируется с помощью OpenResty);

  • Аутентификация для сотрудников;

  • Аутентификация для клиентов.

Закрытый доступ к сайтам и инфраструктуре

Начну со схемы, как мы организовали инфраструктуру доступов.

Упрощенная схема взаимодействия между пользователями и серверамиУпрощенная схема взаимодействия между пользователями и серверами

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

Фронт-контроллером в данном случае выступает OpenResty это модифицированная версия nginx, которая в т. ч. поддерживает из коробки язык Lua. На нём я написал прослойку, через которую проходят все HTTP(s)-запросы.

Вот так может выглядеть код скрипта аунтентификации (в упрощенном варианте):

auth.lua
function authenticationPrompt()    ngx.header.www_authenticate = 'Basic realm="Restricted by OpenResty"'    ngx.exit(401)endfunction hasAccessByIp()    local ip = ngx.var.remote_addr    local domain = ngx.var.host    local port = ngx.var.server_port    local res, err = httpc:request_uri(os.getenv("AUTH_API_URL") .. "/ip.php", {        method = "GET",        query = "ip=" .. ip .. '&domain=' .. domain .. '&port=' .. port,        headers = {          ["Content-Type"] = "application/x-www-form-urlencoded",        },        keepalive_timeout = 60000,        keepalive_pool = 10,        ssl_verify = false    })    if res ~= nil then        if (res.status == 200) then            session.data.domains[domain] = true            session:save()            return true        elseif (res.status == 403) then            return false        else            session:close()            ngx.say("Server error: " .. res.body)            ngx.exit(500)        end    else        session:close()        ngx.say("Server error: " .. err)        ngx.exit(500)    endendfunction hasAccessByLogin()    local header = ngx.var.http_authorization    local domain = ngx.var.host    local port = ngx.var.server_port    if (header ~= nil) then        header = ngx.decode_base64(header:sub(header:find(' ') + 1))        login, password = header:match("([^,]+):([^,]+)")        if login == nil then            login = ""        end        if password == nil then            password = ""        end        local res, err = httpc:request_uri(os.getenv("AUTH_API_URL") .. '/login.php', {            method = "POST",            body = "username=" .. login .. '&password=' .. password .. '&domain=' .. domain .. '&port=' .. port,            headers = {              ["Content-Type"] = "application/x-www-form-urlencoded",            },            keepalive_timeout = 60000,            keepalive_pool = 10,            ssl_verify = false        })        if res ~= nil then            if (res.status == 200) then                session.data.domains[domain] = true                session:save()                return true            elseif (res.status == 403) then                return false            else                session:close()                ngx.say("Server error: " .. res.body)                ngx.exit(500)            end        else            session:close()            ngx.say("Server error: " .. err)            ngx.exit(500)        end    else        return false    endendos = require("os")http = require "resty.http"httpc = http.new()session = require "resty.session".new()session:start()if (session.data.domains == nil) then    session.data.domains = {}endlocal domain = ngx.var.hostif session.data.domains[domain] == nil then    if (not hasAccessByIp() and not hasAccessByLogin()) then        session:close()        authenticationPrompt()    else        session:close()    endelse    session:close()end

Алгоритм работы скрипта довольно простой:

  • Поступает HTTP-запрос от пользователя;

  • OpenResty запускает скрипт auth.lua;

  • Скрипт определяет запрашиваемый домен и отправляет два запроса на внешний бэкенд;

  • Первый на проверку IP-адреса пользователя в базу;

  • Если IP отсутствует, выводит браузерное окно для ввода логина и пароля, отправляет второй запрос на проверку доступа;

  • В любой другой ситуации выводит окно Вход.

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

Немного расскажу о том, как выглядит управление в нашей системе.

Отмечу, что IP-адреса попадают в базу при аутентификации пользователя. Это сделано специально для сотрудников. Такие адреса помечают как временные и доступные ограниченное время, после чего они удаляются.

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

Для клиентов же создаются простые доступы по паре логин-пароль. Такие доступы ограничиваются в пределах определенных доменных адресов.

Управление доступами по паролю и по IP-адресуУправление доступами по паролю и по IP-адресу

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

Форма аутентификации для сотрудниковФорма аутентификации для сотрудников

Каждый день мы начинаем работу с данной аутентификации, таким образом добавляя свой IP-адрес в белый список для получения доступа к инфраструктуре.

Двухфакторная аутентификация

Для обеспечения двухфакторной аутентификации для сотрудников решено было добавить Google Authenticator. Такой механизм защиты позволяет больше обезопасить себя от утечки доступов. Для PHP есть готовая библиотека sonata-project/GoogleAuthenticator. Пример интеграции можно посмотреть здесь.

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

OAuth и OpenID

Третье, и не менее важное для нас, создание OAuth-сервера. За основу мы взяли модуль thephpleague/oauth2-server. Для Yii 2 готового решения не было, поэтому написали собственную имплементацию сервера. OAuth2 достаточно обширная тема, расписывать её работу в данной статье не буду. Библиотека имеет хорошую документацию. Также она поддерживает различные фреймворки, включая Laravel и Symfony.

Таким образом, любой сторонний сервис, который поддерживает кастомные OAuth2 конфигурации, достаточно просто подключается к нашей системе. Значимой фишкой такой интеграции стало подключение нашего ID к Mattermost. Последний в бесплатной версии поддерживает только аутентификацию с помощью GitLab, которую удалось эмулировать через наш сервис.

Также для всех наших проектов на Yii был разработан модуль для подключения ID. Это позволило вынести всё управление доступами в админпанели для сотрудников в централизованное место. Кстати, если интересно, я писал статью о модульном подходе, который мы применили в нашем digital-агентстве.

Заключение

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

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

Ссылки по теме

Подробнее..

Как получить OpenIDOAuth2 токен для тестирования front-end rest сервисов?

27.06.2020 20:16:09 | Автор: admin
Сейчас трудно встретить систему в которая бы не была rest и не использовала OAuth. Особенностью архитектуры таких систем является необходимость наличия валидного токена для доступа к требуемому Frontend Business REST API в HTTP заголовке (хэдере) Authorization: Bearer TOKEN.

Поэтому если мы хотим напрямую проводить тесты через фронтальный rest нам нужен этот токен. Вопрос как его взять? И так это сделать красиво и правильно? Вот тут я не знаю.

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

Есть обычная типовая система с веб рест фронтом и типовым Single-Page-Application браузерным клиентом на JS. Аутентификация и авторизация KeyCloak с Authorization Code Grant.
Надо обеспечить регулярное нагрузочное тестирование фронтовых рест сервисов.

Задача достаточная простая, если у нас есть токены которые мы можем просто вставить в заголовок и использовать JMeter для генерации необходимого потока запросов. Вот тут я и споткнулся, веб браузер получает токен просто и естественно (KeyCloak JS), но как его получить без браузера методом последовательных HTTP запросов и без исполнения JS я так и не понял
Токен в системе проверяется непосредственно в рест сервисе, а не на API Gateway. Отключить проверку нельзя ибо нет такой возможностью. Просто тестировать без токена не получится.

Далее мы подумали, а почему бы не использовать имеющиеся у нас функциональные end-to-end Selenium тесты, но быстро отказались от этого так как необходимый ресурс одновременно работающих браузеров оказался достаточно велик. Для минимум 50 потоков нам нужны были 50Гб + 25 ядер. Однако, это дало нам идею что токен можно получить через Селениум, а далее передать его в JMeter для использования.

В результате, по быстрому был сделан MVP по следующей схеме:
  • Повышаем время жизни токенов для тестового окружения
  • Отключаем кэши в системе, чтобы сымитировать уникальность пользователей.
  • С помощью Selenium приложения поводим процедуры логина тестовой группы пользователей и сбрасываем в файл их токены. Для чтения токена используем вызов JS через WebDriver return keykcloak.token;
  • С помощью JMeter проводим нагрузочные тестирования с использование токенов пользователей
  • Отчет JMeter всем нравиться




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

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

А мне вот решение кажется кривым. Ну должен же быть способ обойтись без селениума! Напишите если кто знает. Я не смог ничего нагуглить на тему тестирования OpenID&OAuth2.
Подробнее..

Подключение Keycloak к Spring Boot приложению

14.04.2021 12:16:12 | Автор: admin

Привет Хабр!

Как известно, spring OAuth2.0.x переведен в режим поддержки уже почти как 2 года назад , а большая часть его функциональности теперь доступна в spring-security (матрица сопоставления). В spring-security отказались переносить Authorization service (roadmap) и предлагают использовать вместо него свободные или платные аналоги, в частности keycloak. В этом посте мы хотели бы поделится различными вариантами подключения keycloak к приложениям spring-boot.

Содержание

Немного о Keycloak

Это реализация SSO (Single sign on) с открытым исходным кодом для управления идентификацией и доступом пользователей.

Основной функционал, поддерживаемый в Keycloak:

  • Single-Sign On and Single-Sign Out.

  • OpenID/OAuth 2.0/SAML.

  • Identity Brokering аутентификация с помощью внешних OpenID Connect или SAML.

  • Social Login поддержка Google, GitHub, Facebook, Twitter.

  • User Federation синхронизация пользователей из LDAP и Active Directory серверов.

  • Kerberos bridge использование Kerberos сервера для автоматической аутентификации пользователей.

  • Гибкое управление политиками через realm.

  • Адаптеры для JavaScript, WildFly, JBoss EAP, Fuse, Tomcat, Jetty, Spring.

  • Возможность расширения с использованием плагинов.

  • И многое-многое другое...

Запускаем и настраиваем keycloak

Для запуска keycloak на машине разработчика удобно использовать docker-compose. В этом случае мы можем в разное время для разных приложений запускать свой сервис авторизации, тем самым избавляя себя от кучи проблем, связанных с конфигурацией под различные приложения. Ниже приведен один из вариантов конфигурации docker-compose для запуска standalone сервера с базой данных postgres:

docker-compose.yml
version: "3.8"services:  postgres:    container_name: postgres    image: library/postgres    environment:      POSTGRES_USER: ${POSTGRES_USER:-postgres}      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}      POSTGRES_DB: keycloak_db    ports:      - "5432:5432"    restart: unless-stopped  keycloak:    image: jboss/keycloak    container_name: keycloak    environment:      DB_VENDOR: POSTGRES      DB_ADDR: postgres      DB_DATABASE: keycloak_db      DB_USER: ${POSTGRES_USER:-postgres}      DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}      KEYCLOAK_USER: admin      KEYCLOAK_PASSWORD: admin_password    ports:      - "8484:8080"    depends_on:      - postgres    links:      - "postgres:postgres"

После успешного запуска необходимо произвести настройки realm, клиентов, ролей и пользователей.

Произведем некоторые первоначальные настройки. Создадим realm "my_realm":

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

Не забываем указывать redirect_url. В нашем случае он будет равен: http://localhost:8080/*

Создадим роли для пользователей нашей системы - "ADMIN", "USER":

Добавляем пользователей "admin" с ролью "ADMIN":

И пользователя "user" с ролью "USER". Не забываем устанавливать пароли на вкладке "Credentials":

Основная настройка закончена, теперь можно приступить к подключению spring boot приложений.

Подключаем Keycloak при помощи адаптера

В официальной документации к keycloak для использования в приложениях рекомендуют использовать готовые библиотеки - адаптеры, которые дают возможность избавиться от boilerplate кода и излишнего конфигурирования. Есть реализация для большинства популярных языков и фреймворков (supported-platforms). Мы будем использовать Spring Boot Adapter.

Создадим небольшое демонстрационное, приложение на spring-boot (исходники можно найти здесь) и подключим к нему Keycloak Spring Boot адаптер. Конфигурационный файл maven будет выглядеть так:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.9.RELEASE</version><relativePath /> <!-- lookup parent from repository --></parent><groupId>org.akazakov.keycloak</groupId><artifactId>demo-keycloak-adapter</artifactId><version>0.0.1-SNAPSHOT</version><name>Demo Keycloak Adapter</name><description>Demo project for Spring Boot and Keycloak</description><properties><java.version>11</java.version></properties><dependencyManagement><dependencies><dependency><groupId>org.keycloak.bom</groupId><artifactId>keycloak-adapter-bom</artifactId><version>12.0.3</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.keycloak</groupId><artifactId>keycloak-spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies></project>

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

@RestController@RequestMapping("/api")public class SampleController {    @GetMapping("/anonymous")    public String getAnonymousInfo() {        return "Anonymous";    }    @GetMapping("/user")    @PreAuthorize("hasRole('USER')")    public String getUserInfo() {        return "user info";    }    @GetMapping("/admin")    @PreAuthorize("hasRole('ADMIN')")    public String getAdminInfo() {        return "admin info";    }    @GetMapping("/service")    @PreAuthorize("hasRole('SERVICE')")    public String getServiceInfo() {        return "service info";    }    @GetMapping("/me")    public Object getMe() {        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();        return authentication.getName();    }}

Чтобы наше приложение успешно запустилось и подключилось к keycloak, нам необходимо добавить соответствующую конфигурацию. Первое, что мы сделаем, это в application.yml добавим настройки клиента и подключения к серверу авторизации:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak}keycloak:  auth-server-url: http://localhost:8484/auth  realm: my_realm  resource: my_client  public-client: true

После этого добавим конфигурацию spring-security, переопределим KeycloakWebSecurityConfigurerAdapter, поставляемый вместе с адаптером:

@KeycloakConfiguration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {    @Override    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {        return new NullAuthenticatedSessionStrategy();    }    @Autowired    public void configureGlobal(AuthenticationManagerBuilder authManagerBuilder) {        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());        authManagerBuilder.authenticationProvider(keycloakAuthenticationProvider);    }    @Bean    public KeycloakConfigResolver keycloakConfigResolver() {        return new KeycloakSpringBootConfigResolver();    }    @Override    protected void configure(HttpSecurity http) throws Exception {        super.configure(http);        http                .authorizeRequests()                .antMatchers("/api/anonymous/**").permitAll()                .anyRequest().fullyAuthenticated();    }}

Теперь проверим работу нашего приложения. Запустим приложение и попробуем зайти пользователем на соответствующий url. Например: http://localhost:8080/api/admin. В результате, браузер перенаправит нас на окно логина пользователя:

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

Если перейдем по адресу получения информации о текущем пользователе (http://localhost:8080/api/me), то получим в результате uuid пользователя в keycloak:

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

keycloak:  auth-server-url: http://localhost:8484/auth  realm: my_realm  resource: my_client  public-client: true  bearer-only: true

Используем OAuth2 Client из spring-security

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

Одной из ключевых особенностей spring security 5 является поддержка протоколов OAuth2 и OIDC. Мы можем использовать OAuth2 клиент из пакета spring-security для интеграции с сервером keycloak.

Итак, для использования клиента подключим соответствующую библиотеку в зависимости от проекта (исходный код примера). Полный текст pom.xml:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"         xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.9.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>org.akazakov.keycloak</groupId>    <artifactId>demo-keycloak-oauth</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>demo-keycloak-oauth</name>    <description>Demo project for Spring Boot OAuth and Keycloak</description>    <properties>        <java.version>11</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-oauth2-client</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-security</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-test</artifactId>            <scope>test</scope>        </dependency>        <dependency>            <groupId>org.springframework.security</groupId>            <artifactId>spring-security-test</artifactId>            <scope>test</scope>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>

Далее в application.yaml необходимо указать параметры подключения к сервису авторизации:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak-oauth}  security:    oauth2:      client:        provider:          keycloak:            issuer-uri: http://localhost:8484/auth/realms/my_realm        registration:          keycloak:            client-id: my_client

По умолчанию роли пользователей будут вычисляться на основе значения "scope" в access token, и к ним прибавляется "ROLE_USER" для всех авторизованных пользователей системы. Можно оставить как есть и перейти на модель scope. Но в нашем примере мы будем использовать роли пользователей в рамках realm'а. Все, что нам нужно, это переопределить oidcUserService и задать свой маппинг ролей для пользователя. Нужные роли приходят в разделе "groups" токена доступа, его мы и будем использовать для определения ролей пользователя. В результате, наша конфигурация для spring security с переопределенным oidcUserService будет выглядеть так:

@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/api/anonymous/**").permitAll()                        .anyRequest().authenticated())                .oauth2Login(oauth2Login -> oauth2Login                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint                                .oidcUserService(this.oidcUserService())                        )                );    }    @Bean    public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {        final OidcUserService delegate = new OidcUserService();        return (userRequest) -> {            OidcUser oidcUser = delegate.loadUser(userRequest);            final Map<String, Object> claims = oidcUser.getClaims();            final JSONArray groups = (JSONArray) claims.get("groups");            final Set<GrantedAuthority> mappedAuthorities = groups.stream()                    .map(role -> new SimpleGrantedAuthority(("ROLE_" + role)))                    .collect(Collectors.toSet());            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());        };    }}

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

Подключаем приложение как ResourceService

Довольно часто не нужно, чтобы наше приложение инициировало аутентификацию пользователя. Достаточно лишь проверки авторизации пользователя по предоставляемому токену доступа. Вариантом подключения авторизации с keycloak без использования адаптера является настройка приложения как resource server. В этом случае приложение не может инициировать аутентификацию пользователя, а только авторизует пользователя и проверяет подпись токена доступа. Подключим соответствующие библиотеки: spring-security-oauth2-resource-server и spring-security-oauth2-jose (исходный код). Полный файл pom.xml будет выглядеть так:

pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="<http://maven.apache.org/POM/4.0.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"xsi:schemaLocation="<http://maven.apache.org/POM/4.0.0> <https://maven.apache.org/xsd/maven-4.0.0.xsd>"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.9.RELEASE</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>org.akazakov.keycloak</groupId><artifactId>demo-keycloak-resource</artifactId><version>0.0.1-SNAPSHOT</version><name>demo-keycloak-resource</name><description>Demo project for Spring Boot and Spring security and Keycloak</description><properties><java.version>11</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-resource-server</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>

Далее нам необходимо указать путь к JWK (JSON Web Key) набору ключей, с помощью которых наше приложение будет проверять токены доступа. В keycloak они доступны по адресу: http://${host}/auth/realms/${realm)/protocol/openid-connect/certs. В итоге application.yml будет выгдядеть следующим образом:

server:  port: ${SERVER_PORT:8080}spring:  application.name: ${APPLICATION_NAME:spring-security-keycloak-resource}  security:    oauth2:      resourceserver:        jwt:          jwk-set-uri: ${KEYCLOAK_REALM_CERT_URL:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/certs}

Как и в случае с OAuth2 Client нам также необходимо переопределить конвертер ролей пользователя. В данном случае мы можем переопределить jwtAuthenticationConverter.

Полный текст WebSecurityConfiguration:

@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/api/anonymous/**").permitAll()                        .anyRequest().authenticated())                .oauth2ResourceServer(resourceServerConfigurer -> resourceServerConfigurer                        .jwt(jwtConfigurer -> jwtConfigurer                                .jwtAuthenticationConverter(jwtAuthenticationConverter()))                );    }    @Bean    public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());        return jwtAuthenticationConverter;    }    @Bean    public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {        JwtGrantedAuthoritiesConverter delegate = new JwtGrantedAuthoritiesConverter();        return new Converter<>() {            @Override            public Collection<GrantedAuthority> convert(Jwt jwt) {                Collection<GrantedAuthority> grantedAuthorities = delegate.convert(jwt);                if (jwt.getClaim("realm_access") == null) {                    return grantedAuthorities;                }                JSONObject realmAccess = jwt.getClaim("realm_access");                if (realmAccess.get("roles") == null) {                    return grantedAuthorities;                }                JSONArray roles = (JSONArray) realmAccess.get("roles");                final List<SimpleGrantedAuthority> keycloakAuthorities = roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).collect(Collectors.toList());                grantedAuthorities.addAll(keycloakAuthorities);                return grantedAuthorities;            }        };    }}

Здесь мы создаем конвертер (jwtGrantedAuthoritiesConverter), который принимает токен и извлекает из секции "realm_access" роли пользователя. Далее мы можем либо сразу вернуть их, либо, как в данном случае, расширить список, который извлекается конвертером по умолчанию.

Проверим работу. Воспользуемся встроенным в Intellij idea http клиентом, либо плагином к VSCode - Rest Client. В начале получим токен пользователя, произведем запрос к keycloak, используя логин и пароль зарегистрированного пользователя:

###POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token>Content-Type: application/x-www-form-urlencodedclient_id=my_client&grant_type=password&scope=openid&username=admin&password=admin> {% client.global.set("auth_token", response.body.access_token); %}

Ответ будет примерно следующего содержания:

Ответ
POST <http://localhost:8484/auth/realms/my_realm/protocol/openid-connect/token>HTTP/1.1 200 OK...Content-Type: application/json{  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMGQwMjg2YWUtYTlmYy00MzcxLWFmM2ItZjJlNTM5N2I4NzViIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjkzMGIxMTNmLWI0NzUtNDhkMC05NTQxLWMyYzI2MWZlYmRmZCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiQURNSU4iLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.dvGvYhhhfH8r6EP8k_spFwBS35ulYMTWNL4lcz9PR2e-p4FU-ehre1EQA8xpbkYzYEWRB_elzTya5IhbYR8KArrujplIDNAOlqJ9W6a4Tx-r44QCteM0DW4BNzbZAH2L0Bg7aSstRKUuULceRNYQcdCvSFjEU5DsHk26a6TM5KCrkv0ryGo11pam-pnbs2Z2jOSfSHvOAfMNL9OVJYRBjlTmsEzzgH9dHSa_pT2Q-SvgvfCcwfY0XkgUZkMPUtz85-lqchROb4XpHOiy3Cfn8MgrGNwhf-MsmN5wiAGe0DI_LW2Jxr3boZMLS4AuuNQ7agr65g-JuO9-LhlgndxN8g",  "expires_in": 300,  "refresh_expires_in": 1800,  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNGEwNWQxNy0yNWU4LTRjMjEtOTMyMC0zMzcwODlhNTg5MjQifQ.eyJleHAiOjE2MTY2NTU4NjEsImlhdCI6MTYxNjY1NDA2MSwianRpIjoiMjNmNDBiZWUtNmQ3Ny00ZTIxLTg0NTItNDg1NDc2OTk1ZDUyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL215X3JlYWxtIiwic3ViIjoiOTMwYjExM2YtYjQ3NS00OGQwLTk1NDEtYzJjMjYxZmViZGZkIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIn0.r4BrjwfavKFF8dst3AyRi0LTfymbSVfDKDT9KyMpmzk",  "token_type": "bearer",  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlb21qWFY2d3dNek8xVS0tYUdhVllpSHM3eURaZVM1aU96bl9JR3RlS1ZzIn0.eyJleHAiOjE2MTY2NTQzNjEsImlhdCI6MTYxNjY1NDA2MSwiYXV0aF90aW1lIjowLCJqdGkiOiJiN2UwNDhmZS01ZTRjLTQxMWYtYTBjMC0xNGExYzhlOGJhYWEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0ODQvYXV0aC9yZWFsbXMvbXlfcmVhbG0iLCJhdWQiOiJteV9jbGllbnQiLCJzdWIiOiI5MzBiMTEzZi1iNDc1LTQ4ZDAtOTU0MS1jMmMyNjFmZWJkZmQiLCJ0eXAiOiJJRCIsImF6cCI6Im15X2NsaWVudCIsInNlc3Npb25fc3RhdGUiOiI1ZDI5ZDQ2ZS1iOTI2LTRkNTktODlmOC0yNDM2ZWRjYWU0ZjAiLCJhdF9oYXNoIjoiRlh2VzB2Z3pwd3R6N1FabEZtTFhJdyIsImFjciI6IjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.ZDeZg4Z-PPmn2fVm7opGLRutzDh6l8uRYqZzbqIX7wk0GhgtMHV1CW8RvDd51AuYw81WyoMyRAD_-T6ne58Rt9f5XNZZfS8xoXzTFV1xH6XigOVQH2jIHN-2VIM1IgJnteo7nuTz9zo4OXIFvEjaFHq4AXDkiq6jhThv0qPS3WrAA-MutyW8G37GM0fsCgANvlGKoWm1_1wKyeTZ0Gfug32Vf6gUikfxA9bmaS4oGYGc6lqFE6EHgtjIn0q9gNUfpEXaqpiL3mCBu9V6sJG5Rp_MOqp-aXrM9NbLTz2JTXevtClHI6qVUIoh8OXXXT98QmKrVr9Cyr9BRUrQyt0Zzg",  "not-before-policy": 0,  "session_state": "5d29d46e-b926-4d59-89f8-2436edcae4f0",  "scope": "openid profile email"}Response code: 200 (OK); Time: 114ms; Content length: 2987 bytes

Теперь проверим, что методы доступны пользователю с соответствующими правами:

GET <http://localhost:8080/api/admin>Authorization: Bearer {{auth_token}}Content-Type: application/json

В ответ получим:

GET <http://localhost:8080/api/admin>HTTP/1.1 200 ...admin infoResponse code: 200; Time: 34ms; Content length: 10 bytes

Авторизация вызовов сервисов с использованием keycloak

При работе с микросервисной архитектурой иногда возникают требования авторизованных вызовов между сервисами. В случаях, когда инициатором взаимодействия является какой-то внутренний процесс или служба, нам где-то нужно брать токен доступа. В качестве решения данного вопроса мы можем использовать Client Credentials Flow, чтобы получить токен из keycloak (исходный код примера доступен по ссылке).

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

Для возможности авторизации сервиса нам нужно изменить тип доступа ("Access Type") на "confidential" и включить флаг "Service accounts Enabled". В остальном конфигурация не отличается от конфигурации по умолчанию:

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

Далее эту роль необходимо добавить клиенту. На вкладке "Service Account Roles" выбираем необходимую роль - в нашем случае роль "SERVICE":

Сохраняем client_id и client_secret для дальнейшего использования в сервисах для авторизации:

Для демонстрации создадим небольшое приложение, которое будет получать информацию доступную по адресу http://localhost:8080/api/service из предыдущих примеров.

Для начала создадим компонент, который будет авторизовывать наш сервис в keycloak:

@Componentpublic class KeycloakAuthClient {    private static final Logger log = LoggerFactory            .getLogger(KeycloakAuthClient.class);    private static final String TOKEN_PATH = "/token";    private static final String GRANT_TYPE = "grant_type";    private static final String CLIENT_ID = "client_id";    private static final String CLIENT_SECRET = "client_secret";    public static final String CLIENT_CREDENTIALS = "client_credentials";    @Value("${app.keycloak.auth-url:http://localhost:8484/auth/realms/my_realm/protocol/openid-connect}")    private String authUrl;    @Value("${app.keycloak.client-id:service_client}")    private String clientId;    @Value("${app.keycloak.client-secret:acb719cf-4afd-42d3-91f2-93a60b3f2023}")    private String clientSecret;    private final RestTemplate restTemplate;    public KeycloakAuthClient(RestTemplate restTemplate) {        this.restTemplate = restTemplate;    }    public KeycloakAuthResponse authenticate() {        MultiValueMap<String, String> paramMap = new LinkedMultiValueMap<>();        paramMap.add(CLIENT_ID, clientId);        paramMap.add(CLIENT_SECRET, clientSecret);        paramMap.add(GRANT_TYPE, CLIENT_CREDENTIALS);        HttpHeaders headers = new HttpHeaders();        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);        String url = authUrl + TOKEN_PATH;        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(paramMap, headers);        log.info("Try to authenticate");        ResponseEntity<KeycloakAuthResponse> response =                restTemplate.exchange(url,                        HttpMethod.POST,                        entity,                        KeycloakAuthResponse.class);        if (!response.getStatusCode().is2xxSuccessful()) {            log.error("Failed to authenticate");            throw new RuntimeException("Failed to authenticate");        }        log.info("Authentication success");        return response.getBody();    }}

Метод authenticate производит вызов к keycloak и в случае успешного ответа возвращает объект KeycloakAuthResponse:

public class KeycloakAuthResponse {    @JsonProperty("access_token")    private String accessToken;    @JsonProperty("expires_in")    private Integer expiresIn;    @JsonProperty("refresh_expires_in")    private Integer refreshExpiresIn;    @JsonProperty("refresh_token")    private String refreshToken;    @JsonProperty("token_type")    private String tokenType;    @JsonProperty("id_token")    private String idToken;    @JsonProperty("session_state")    private String sessionState;    @JsonProperty("scope")    private String scope;    // Getters and setters or lombok ...}

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

@SpringBootApplicationpublic class DemoServiceAuthApplication implements CommandLineRunner {    private static final String BEARER = "Bearer ";    private static final String SERVICE_INFO_URL = "http://localhost:8080/api/service";    private final KeycloakAuthClient keycloakAuthClient;    private final RestTemplate restTemplate;    private static final Logger log = LoggerFactory            .getLogger(DemoServiceAuthApplication.class);    public DemoServiceAuthApplication(KeycloakAuthClient keycloakAuthClient, RestTemplate restTemplate) {        this.keycloakAuthClient = keycloakAuthClient;        this.restTemplate = restTemplate;    }    public static void main(String[] args) {        SpringApplication.run(DemoServiceAuthApplication.class, args);    }    @Override    public void run(String... args) {        final KeycloakAuthResponse authenticate = keycloakAuthClient.authenticate();        HttpHeaders headers = new HttpHeaders();        headers.setContentType(MediaType.APPLICATION_JSON);        headers.setBearerAuth(authenticate.getAccessToken());        log.info("Make request to resource server");        final ResponseEntity<String> responseEntity = restTemplate.exchange(SERVICE_INFO_URL, HttpMethod.GET, new HttpEntity(headers), String.class);        if (!responseEntity.getStatusCode().is2xxSuccessful()) {            log.error("Failed to request");            throw new RuntimeException("Failed to request");        }        log.info("Response data: {}", responseEntity.getBody());    }}

Сначала мы авторизуем наш сервис через keycloak, потом производим запрос к защищенному ресурсу, добавив в HTTP Headers параметр Authorization: Bearer ...

В результате выполнения программы мы получим содержимое защищенного метода:

.   ____          _            __ _ _ /\\\\ / ___'_ __ _ _(_)_ __  __ _ \\ \\ \\ \\( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\ \\\\/  ___)| |_)| | | | | || (_| |  ) ) ) )  '  |____| .__|_| |_|_| |_\\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot ::                (v2.4.4)2021-04-13 16:04:36.672  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Starting DemoServiceAuthApplication using Java 14.0.1 on MacBook-Pro.local with PID 19240 (/Users/akazakov/Projects/spring-boot-keycloak/demo-service-auth/target/classes started by akazakov in /Users/akazakov/Projects/spring-boot-keycloak)2021-04-13 16:04:36.674  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : No active profile set, falling back to default profiles: default2021-04-13 16:04:37.199  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Started DemoServiceAuthApplication in 0.814 seconds (JVM running for 6.425)2021-04-13 16:04:37.203  INFO 19240 --- [           main] o.akazakov.keycloak.KeycloakAuthClient   : Try to authenticate2021-04-13 16:04:53.697  INFO 19240 --- [           main] o.akazakov.keycloak.KeycloakAuthClient   : Authentication success2021-04-13 16:04:53.697  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Make request to resource server2021-04-13 16:04:54.088  INFO 19240 --- [           main] o.a.keycloak.DemoServiceAuthApplication  : Response data: service infoDisconnected from the target VM, address: '127.0.0.1:57479', transport: 'socket'Process finished with exit code 0

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

Выводы

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

Спасибо за внимание!

Подробнее..

Категории

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

  • Имя: Макс
    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