Прикручиваем авторизацию на основе KeyCloak к веб-приложению

Жизненный цикл разработки корпоративного приложения в нашей компании привел меня к, по сути, банальной задаче — “прикручиванию” пользовательской авторизации к фронту приложения. Пользователями приложения должны быть сотрудники компании, а идентификационным провайдером должен стать корпоративный Active Directory (далее AD).

Было принято решение не работать с AD напрямую при авторизации сотрудников в корпоративных приложениях, а использовать для этого довольно удобный брокер авторизации – KeyCloak.

Почему? Вот несколько причин:

  • KeyCloak имеет встроенную реализацию протокола OpenId Connect и готовое SSO решение

  • KeyCloak поддерживает большой набор идентификационных провайдеров, помимо AD. Это позволяет избежать отдельных интеграций приложений с каждым провайдером, а приложения авторизуются “на одном языке”

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

KeyCloak: Настройки

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

В рамках статьи основные исходные настройки KeyCloak следующие:

realm – myproject (Зарегистрированное в KeyCloak имя проекта для которого разрабатывается приложение)

client_id – myproject-app (Зарегистрированное в KeyCloak имя приложения)

Более подробно о этих параметрах – ниже.

Сервис авторизации

Итак, первое что нужно для нашей задачи – сервис авторизации (далее auth server) осуществляющий авторизацию пользователей (предоставление access_token), продление токена (refresh_token) и валидацию токена. Сам сервис будет максимально простым и содержать минимум boilerplate кода, потому что большая его частьбудет переложена на “плечи” KeyCloak.

Поскольку сам auth server будет написан на Spring Boot, для его интеграции с KeyCloak будем использовать Spring Boot KeyCloak Adapter. Данное решение рекомендуют сами разработчики KeyCloak.

Конфигурация сервиса

Создадим проект auth server и добавим в него необходимые зависимости. Конфигурационный файл 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.6.1</version> <relativePath/> </parent> <groupId>ru.myproject</groupId> <artifactId>auth-keycloak</artifactId> <version>1.1</version> <name>auth-keycloak</name> <description>KeyCloak Corporate Auth Service</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</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.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.8</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> <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>
</project>

Теперь, после того, как мы подключили необходимые библиотеки к проекту, нам необходимо сконфигурировать его для взаимодействия с KeyCloak. Файл конфигурации application.yml будет выглядеть так:

keycloak: auth-server-url: http://keycloak.host realm: myproject resource: myproject-app public-client: true use-resource-role-mappings: true

auth-server-url  — путь до сервиса KeyCloak

realm – имя проекта, для которого создано пользовательское пространство в KeyCloak

resource – имя приложения, оно же client_id в KeyCloak

use-resource-role-mappings – флаг, указывающий что надо учитывать роли
находящиеся в секции ресурсного уровня JWT-токена. Подробнее об уровнях расположения ролей в токене — тут

Теперь добавим конфигурацию 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("/authenticate", "/refresh").permitAll() .anyRequest().fullyAuthenticated(); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/authenticate", "/refresh"); }
}

Здесь мы добавили в исключения 2 маппинга – /authenticate и /refresh, чтобы наш auth server смог их обрабатывать как неавторизированные, а пользователь — авторизоваться и обновить токен.

Клиент для KeyCloak

Клиент для KeyCloak будет содержать 2 метода:

Оба метода будут возвращать класс org.keycloak.representations.AccessTokenResponse.class из подключенной ранее библиотеки. Структура класса соответствует спецификации OpenId Connect для Successful Token Response.

Код клиента будет выглядеть так:

@Component
@RequiredArgsConstructor
public class KeyCloakClient { @Value("${keycloak.auth-server-url}") private String keyCloakUrl; @Value("${keycloak.resource}") private String clientId; @Value("${keycloak.realm}") private String realm; private final RestTemplate restTemplate; public AccessTokenResponse authenticate(AuthRequestDto request) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(); parameters.add("username",request.getUsername()); parameters.add("password",request.getPassword()); parameters.add("grant_type", "password"); parameters.add("client_id", clientId); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(parameters, headers); return restTemplate.exchange(getAuthUrl(), HttpMethod.POST, entity, AccessTokenResponse.class).getBody(); } public AccessTokenResponse refreshToken(String refreshToken) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(); parameters.add("grant_type", "refresh_token"); parameters.add("client_id", clientId); parameters.add("refresh_token", refreshToken); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(parameters, headers); return restTemplate.exchange(getAuthUrl(), HttpMethod.POST, entity, AccessTokenResponse.class).getBody(); } private String getAuthUrl() { return UriComponentsBuilder.fromHttpUrl(keyCloakUrl) .pathSegment("realms") .pathSegment(realm) .pathSegment("protocol") .pathSegment("openid-connect") .pathSegment("token") .toUriString(); }
}

Здесь для построения ссылки обращения к KeyCloak и формирования параметров заголовка, для интеграции с KeyCloak мы используем параметры конфигурации application.yml, внесенные ранее.

Параметры заголовка указали следующие:

grant_type – тип запроса на получение токена. Мы будем использовать тип «password» для запроса при авторизации пользователя и тип «refresh_token» для запроса обновления токена.

client_id – имя приложения зарегистрированного в KeyCloak. Он же параметр keycloak.resource конфигурации KeyCloak Adapter описанный ранее.

username/password – credentials доменной учетки, будут использоваться в запросе получения токена.

refresh_token – собственно refresh_token, будет использоваться в запросе обновления токена.

И в методе аутентификации и refresh токена используем одну и ту же ссылку для обращения к KeyCloak. В соответствие со спецификацией KeyCloak формат ссылки, в нашем случае, будет таким:

http://keycloak.host /auth/realms/myproject/protocol/openid-connect/token.

myproject — realm нашего проекта.

keycloak.host — хост, на котором находится ваш KeyCloak

Контроллеры

Далее нам нужно создать котроллер с методом для аутентификации AuthenticateController.java:

@RestController
@RequiredArgsConstructor
@RequestMapping("/authenticate")
public class AuthenticateController { private final KeyCloakClient keyCloakClient; @PostMapping public ResponseEntity<AccessTokenResponse> authenticate(@RequestBody AuthRequestDto request) { return ResponseEntity.ok(keyCloakClient.authenticate(request)); }
}

Здесь метод аутентификации принимает от клиента (фронта) объект, содержащий логин и пароль. В рамках статьи логин и пароль передаются в открытом виде и в теле запроса. Далее мы делаем запрос в KeyCloak через KeyCloakClient.java и получаем авторизационный ответ от KeyCloak, который передаем обратно клиенту.

Также создадим котроллер для работы с токеном TokenController.java:

@RestController
@RequiredArgsConstructor
public class TokenController { private final KeyCloakClient keyCloakClient; @ExceptionHandler(AuthenticationCredentialsNotFoundException.class) public ResponseEntity handleAuthNotFoundException() { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @GetMapping("/validate") @PreAuthorize("hasRoles('MY_APP_USER', 'MY_APP_ADMIN')") public ResponseEntity validate() { return ResponseEntity.ok().build(); } @PostMapping("/refresh") public ResponseEntity<AccessTokenResponse> refresh(@RequestHeader("refresh-token") String refreshToken) { return ResponseEntity.ok(keyCloakClient.refreshToken(refreshToken)); }
}

В методе /validate – мы проверяем через спринговую аннотацию @PreAuthorize("hasRole('MY_APP_USER')") — содержит ли токен пришедший в запросе необходимую для нас роль. В нашем случае нам нужны роли — «ROLE_MY_APP_USER» и «ROLE_MY_APP_ADMIN» для пользователей и администраторов приложения.

ВАЖНО! Да, в методе hasRole необходимо указывать значение роли без префикса ROLE_ (т.к. спринг по умолчанию подставляет этот префикс при сопоставлении ролей).

Конфигурирование NGINX

Поскольку фронт будет обращаться к нескольким микросервисам, нужна единая точка для взаимодействия фронта с ними. В качестве единой точки был выбран nginx.

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

location /my-servece-1 { auth_request /validate;
  proxy_pass http://ХостСервиса1:Порт;
} location /my-service-2 {
  auth_request /validate;
  proxy_pass http://ХостСервиса2:Порт;
}

Параметр конфигурации auth_request – указывает nginx, что ресурсы (сервисы) запрашиваемые по маппингам /my-service-1 и /my-service-2 могут быть доступны только для авторизованных запросов.

В proxy_pass – указывается путь к самому сервису.

Работает это следующим образом: когда запрашиваются эти ресурсы, Nginx делает предварительно запрос на /validate. В случае если /validate отвечает с http status = 200 OK, то Nginx пропускает далее на запрашиваемый ресурс, если 401 Unauthorized – не пропускает и возвращает этот статус клиенту.

Теперь нам надо прописать тот самый маппинг /validate, который будет обращаться к одноименному rest-методу нашего auth server, который мы создали в контроллере ранее:

location = /validate { internal; proxy_set_header Content-Length ""; proxy_pass http://ХостНашегоAuthServer:Порт/validate;
} 

Также, для того, чтобы мы могли обращаться с фронта к остальным его методам auth server, а именно — методам авторизации и refresh токена, пропишем для него маппинг /auth-server:

location /auth-server { proxy_pass http://ХостНашегоAuthServer:Порт;
}

“Прикручиваем” к фронту

Работа с токенами

Полученные от KeyCloak access_token и refresh_token будем хранить в localStorage. Создадим для работы с токенами и localStorage утилитарный класс TokenUtils.js:

import jwt_decode from 'jwt-decode';
import Keycloak from "keycloak-verify/src"; const TOKEN_KEY = 'myproject_access_token';
const REFRESH_TOKEN_KEY = 'myproject_refresh_token';
const PUBLIC_KEY = '-----BEGIN CERTIFICATE-----[PUBLIC_KEY полученный от KeyCloak]-----END CERTIFICATE-----'; export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = token => localStorage.setItem(TOKEN_KEY, token);
export const getRefreshToken = () => localStorage.getItem(REFRESH_TOKEN_KEY);
export const setRefreshToken = refreshToken => localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); const getDetailsFromToken = () => { return jwt_decode(getToken());
}; export const getUserRoles = () => getDetailsFromToken()?.resource_access["myproject-app"]?.roles;
export const hasRole = role => getUserRoles()?.includes(role); export const verifyToken = () => ( Keycloak( {publicKey: PUBLIC_KEY} ).verifyOffline(getToken())
);

Методы в данном классе будут необходимы для описанных ниже действий. Пройдемся подробно по каждому:

getToken/setToken – getter/setter для получения/установки token из/в localStorage

getRefreshToken/setRefreshToken – getter/setter для получения/установки refreshToken из/в localStorage

getDetailsFromToken – парсинг токена в json-формат (используется ниже)

getUserRoles – получения списка пользовательских ролей, зашитых в access_token. Здесь, как было описано выше, мы берем роли из уровня ресурсов токена (уровня приложения), поэтому обращаемся к полю resource_access и к его дочернему полю, соответствующему нашему client_id (параметр в KeyCloak). В нашем случае это – «myproject-app».

hasRole – проверка наличия необходимой роли у пользователя.

verifyToken – метод клиентской валидации токена.

Здесь поподробнее. Метод необходим для вызова обновления токена в случае, когда токен невалиден (истек срок действия)

Я выбрал для целей валидации токена на фронте библиотеку keycloak-verify. Данная библиотека предполагает 2 варианта валидации токена – offline и online. Online – более безопасный, но и более затратный, т.к. требует обращения фронта к сервису. Поскольку я хочу, чтобы авторизованный фронт обращался к сервису авторизации только в случае необходимости обновления токена, я выбрал offline-вариант.

Для валидации токена offline методом – необходим public_key. Его мы можем получить через консоль управления KeyCloak или сделав следующий запрос — GET http://keycloak.host/auth/realms/myproject

Где myproject – имя нашего realm.

public_key будет находится в одноименном поле ответа:

{ "realm": "myproject", "public_key": "MIIBIjANBgkqhkiG … ZyR6F6wIDAQAB", "token-service": "http://keycloak.host/auth/realms/myproject/protocol/openid-connect", "account-service": "http://keycloak.host/auth/realms/myproject/account", "tokens-not-before": 0
}

Его необходимо хранить где-то в проекте фронтового приложения либо в ENV-переменной. Для простоты мы будем хранить его в утилитарном классе TokenUtils.js.

ВАЖНО: Для корректной работы библиотеки, public_key должен быть помещен внутрь текстового блока:

-----BEGIN CERTIFICATE-----Your public key-----END CERTIFICATE-----

Взаимодействие фронта с микросервисами. AXIOS

Для взаимодействия с микросервисами будем использовать библиотеку AXIOS.

Поскольку у нас будут 2 типа запросов:авторизированные (для работы с микросервисами) и неавторизированные (для самой авторизации и refresh токена) — для каждого типа запроса будем использовать отдельный инстанс axios.

Итак, создадим утилитарный класс с нашими axios – AxiosInstance.js и добавим в него экземпляр axios для авторизированных запросов:

export const axiosInstance = axios.create({ headers: { Authorization: `Bearer ${getToken()}` }
});

Здесь мы добавили в заголовок параметр авторизации со значением – bearer-token. Он будет посылаться в каждом запросе.

Кроме того, перед каждым запросом с фронта нам придется проверять валидность токена и, в случае если он невалиден (истек срок действия например) — делать его refresh. После этого в запросе уйдет уже обновленный валидный token.

Итак, добавим вызов метода verifyToken в axios request interceptor:

axiosInstance.interceptors.request.use(request => { verifyToken().catch(() => refreshToken(getRefreshToken())); return request;
});

В случае, если токен невалиден, метод кидает исключение, в обработке которого вызываем обновление токена методом refreshToken, а в качестве его аргумента передаем refresh_token который хранится в нашем localStorage и доступный через утилитарный метод getRefreshToken.

Теперь осталось только проверить статус ответа в авторизированных запросах. Сделаем это через axios response interceptors:

axiosInstance.interceptors.response.use(response => { if (response?.headers?.authorization) { setToken(response.headers.authorization); } return response;
}, error => { if (error?.response?.status === 401) { logout(); } return Promise.reject(error);
});

Здесь, в случае, если httpStatus = 401, отправляем пользователя на страницу авторизации (делаем logout), предварительно очистив localStorage от access token и refresh token, т.к. они невалидны.

Метод logout в нашем случае будет выглядеть так:

export const logout = () => { clearTokenData() window.location.href = '/';
}

Для неавторизированных запросов (для авторизации и refresh токена) – будем использовать дефолтный:

export const axiosInstanceDefault = axios.create();

Клиент для auth server

Создадим класс AuthClient.js с методом аутентификации и refresh токена:

import { axiosInstanceAuth } from "../services/AxiosInstance";
import {setRefreshToken, setToken} from "./TokenUtils"; const AUTH_SERVICE = "/auth-server"; export const authenticate = async (login, password) => { return axiosInstanceDefault.post(`${AUTH_SERVICE}/authenticate`, { username: login, password: password } ).then(response => { setToken(response.data.access_token); setRefreshToken(response.data.refresh_token); return Promise.resolve(); }).catch(() => Promise.reject());
}; export const refreshToken = async () => { axiosInstanceDefault.post(`${AUTH_SERVICE}/refresh`, getRefreshToken()) .then(response => { setToken(response.data.access_token); setRefreshToken(response.data.refresh_token) }) .catch(() => logout());
};

Здесь, в обоих методах, в случае успеха – устанавливаем в localStorage — access_token и refresh_token с помощью соответствующих утилитарных методов класса TokenUtils.js.

Метод аутентификации в случае успеха возвращает успешный Promise, а в случае ошибки – ошибочный Promise. В них мы добавим соответствующие обработчики в местах вызова метода, подробнее о них – ниже (в разделе “Страница авторизации”).

Для метода обновления токена возвращение Promise не нужно, т.к. при успешном его выполнении никаких дополнительных действий на стороне вызова не потребуется. В случае ошибкиотправляем пользователя на страницу авторизации с помощью метода logout. Метод refreshToken мы уже добавили ранее в axois.request.interceptor для обновления токена в случае необходимости перед каждым axios-запросом.

Страница авторизации

На странице авторизации создадим метод login и повесим его в качестве обработчика на кнопку «Войти».

AuthPage.js:

const login = async () => { await authenticate(credentials.login, credentials.password) .then(() => { window.location.href = '/'; }).catch(() => setError(true));
};

Здесь мы вызываем созданный ранее метод authenticate, а в качестве обработчика успешного выполнения метода передаем редирект на корневой маппинг приложения (‘/’). В качестве обработчика ошибки выполнения метода передаем вызов функции setError, в моем случае – это просто установка хука error, означающего, что надо показать сообщение об ошибке.

Разграничение интерфейса по ролям

Для того чтобы предоставить на фронте доступ к интерфейсу/функционалу соответствующий каждой роли, нам остается вызвать в соответствующих местах ранее созданный метод TokenUtils.hasRole(‘role_name’).

Например, отрисовка элементов Tab будет выглядеть следующим образом:

<Tabs> {(hasRole(‘ROLE_MY_APP_USER’) || hasRole(‘ROLE_MY_APP_ADMIN’)) && <Tab label="Пользовательский tab" value="someUserTab"/>} { hasRole(‘ROLE_MY_APP_ADMIN’) && <Tab label="Администраторский tab" value="someAdminTab"/>}
</Tabs>

Здесь таб для некого абстрактного пользовательского функционала «Пользовательский tab» доступен и для пользовательской роли «ROLE_MY_APP_USER» и для администраторской роли «ROLE_MY_APP_ADMIN».

“Администраторский tab”,соответственно, доступен только для роли «ROLE_MY_APP_ADMIN».

В заключение

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

Стоит отметить, что в случае взаимодействия фронта только с  одним сервисом, стоит пересмотреть решение, исключив nginx, как инструмент для предварительной проверки авторизованности запроса (метод /validate вызываемый каждый раз перед вызовом сервиса). В таком случае весь механизм работы с spring-keycloak-adapter переносится в единственный сервис (back).

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

Читайте так же:

  • Роскомнадзор начал оформлять протоколы по закону о «суверенном Рунете»Роскомнадзор начал оформлять протоколы по закону о «суверенном Рунете» Роскомнадзор составил первые протоколы за отказ компаний от подключения к национальной системе доменных имён (НСДИ), предусмотренного в рамках закона о «суверенном Рунете». По этим протоколам суд имеет право оштрафовать организации на сумму 60—100 тысяч рублей. Газета «Коммерсантъ» взяла […]
  • Динамический контент на сайте как влияет на продвижениеДинамический контент на сайте как влияет на продвижение Когда дело доходит до создания нового веб-сайта, есть множество путей, по которым вы можете пойти. Некоторые инструменты позволят вам создать сайт за считанные минуты. Без необходимости какого-либо сложного кодирования. Однако важно не забывать. Насколько важен язык разметки […]
  • Онлайн-интенсив по запуску рекламы в InstagramОнлайн-интенсив по запуску рекламы в Instagram Описание Бесплатный онлайн-интенсив по запуску рекламы в Instagram с нуля для новичков в таргетированной рекламе и начинающих специалистов.Программа 1 день интенсива, 20 июля:Оформление бизнес-страницы в Facebook и аккаунта в InstagramПошаговая настройка Business Manager в […]
  • Яндекс.Маркет в 6 раз увеличил количество пунктов приема заказовЯндекс.Маркет в 6 раз увеличил количество пунктов приема заказов Яндекс.Маркет расширил сеть точек. Через которые принимает от продавцов-партнеров товары. Уже упакованные для доставки. Раньше магазины могли сдавать заказы через сортировочные центры. А при подключении экспресс-доставки — через водителей. Сотрудничающих с сервисом […]