Прикручиваем авторизацию на основе 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 будет выглядеть так:
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.6.1
ru.myproject
auth-keycloak
1.1
auth-keycloak
KeyCloak Corporate Auth Service
11
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
org.keycloak
keycloak-spring-boot-starter
org.projectlombok
lombok
1.18.8
org.springframework.boot
spring-boot-maven-plugin
org.keycloak.bom
keycloak-adapter-bom
12.0.3
pom
import
Теперь, после того, как мы подключили необходимые библиотеки к проекту, нам необходимо сконфигурировать его для взаимодействия с 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 parameters = new LinkedMultiValueMap<>();
parameters.add("username",request.getUsername());
parameters.add("password",request.getPassword());
parameters.add("grant_type", "password");
parameters.add("client_id", clientId);
HttpEntity> 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 parameters = new LinkedMultiValueMap<>();
parameters.add("grant_type", "refresh_token");
parameters.add("client_id", clientId);
parameters.add("refresh_token", refreshToken);
HttpEntity> 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 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 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 будет выглядеть следующим образом:
{(hasRole(‘ROLE_MY_APP_USER’) || hasRole(‘ROLE_MY_APP_ADMIN’)) && }
{ hasRole(‘ROLE_MY_APP_ADMIN’) && }
Здесь таб для некого абстрактного пользовательского функционала «Пользовательский tab» доступен и для пользовательской роли «ROLE_MY_APP_USER» и для администраторской роли «ROLE_MY_APP_ADMIN».
«Администраторский tab», соответственно, доступен только для роли «ROLE_MY_APP_ADMIN».
В заключение
Для лаконичности статьи я максимально упростил технические решения, пытаясь акцентировать внимание на самом подходе.
Стоит отметить, что в случае взаимодействия фронта только с одним сервисом, стоит пересмотреть решение, исключив nginx, как инструмент для предварительной проверки авторизованности запроса (метод /validate вызываемый каждый раз перед вызовом сервиса). В таком случае весь механизм работы с spring-keycloak-adapter переносится в единственный сервис (back).
Также, возможно, полезно будут следующие материалы: