Разработка приложения с авторизацией пользователя Java 17 + SpringBoot 3.4 + Keycloak 26
Решала на проекте задачу по настройке флоу auth2 для Java приложения с использованием Keycloak в качестве сервера авторизации.
Вроде бы информации много на разных ресурсах и документация есть, но встречаюсь с такими нюансами: версии Keycloak-а меняются так, что утсраевают старые примеры, никто уже не использует их адаптер, на который массу примеров; меняются версии Spring и их примеры тоже быстро устаревают и прошлые варианты реализации всё равно надо по новому переписывать в новых версиях. Плюс для меня это был новый опыт работы с auth2, потому, конечно, множество источников пришлось перелопатить.
Потому решила написать небольшую инструкцию как в общем я реализовывала эту задачу.
Версии на момент разработки:
Java Coretto 17,
SpringBoot 3.4.1,
Keycloak 26.0.7.
Репозиторий: https://github.com/ElenaSpb/keycloak-example
1. Настройка Keycloak для локальной разработки
1.1 Cкачиваем последнюю версию, запускаем.
У меня он скачен в c:\distr\keycloak, перехожу там в папку \bin и запускаю сервер Keycloak командой kc.bat start-dev --http-port 8085 . На порту 8085 в dev профиле делаю.
1.2 При первом запуске он просит создать пользователя temporary admin user, админа сервера то есть, задав логин и пароль, создаю lenas / lenas.
Вот логи:

1.3 Создаю realm lenas-realm:

1.4 Создаю клиента lenas-client:



1.5 Создание пользователя, которым будем логиниться из приложения


2. Разработка Java приложения
2.1 можно возпользоваться Spring Initializer https://start.spring.io/ для создания скелета:

2.2 Прописываем в application.properties нашу конфигурацию из п.1
spring.application.name=lenas-client
server.port=8081
## keycloak
spring.security.oauth2.client.provider.lenas-realm.issuer-uri=http://localhost:8085/realms/lenas-realm
spring.security.oauth2.client.registration.lenas-realm.provider=lenas-realm spring.security.oauth2.client.registration.lenas-realm.client-name=lenas-client-name spring.security.oauth2.client.registration.lenas-realm.client-id=lenas-client-id spring.security.oauth2.client.registration.lenas-realm.client-secret=veS8yqDACC5SDOTwfyX68pnJa81aI2ol
spring.security.oauth2.client.registration.lenas-realm.scope=openid,offline_access,profile
spring.security.oauth2.client.registration.lenas-realm.authorization-grant-type=authorization_code
https://github.com/ElenaSpb/keycloak-example/blob/main/src/main/resources/application.properties
client-secret берем отсюда:

2.3 Прописываем Spring Configuration
https://github.com/ElenaSpb/keycloak-example/blob/main/src/main/java/com/example/keycloak_app/SecurityConfiguration.java
(он пока взят из текущего проекта, на котором версия спринга ниже, пятая, потому многое там подчеркнуто как deprecated, надо на шестой версии как-то все по умному переписать, но пока руки не дошли, не нашла нормальных примеров).
2.4 Пишем простой Контроллер
https://github.com/ElenaSpb/keycloak-example/blob/main/src/main/java/com/example/keycloak_app/controller/IndexController.java
@RestController
public class IndexController {
@GetMapping(path = "/")
public HashMap index() {
OAuth2User user = ((OAuth2User) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
return new HashMap() {{
put("hello", user.getAttribute("name"));
put("your email is", user.getAttribute("email"));
}};
}
@GetMapping(path = "/unauthenticated")
public String unauthenticatedRequests() {
return "this is unauthenticated endpoint";
}
@GetMapping(path = "/cats")
public List getCats() {
return List.of("cat1", "cat2");
}
}
2.5 Запускаем приложение и проверяем
2.5.1 http://localhost:8081/unauthenticated — доступен без авторизации, как и ожидалось.
2.5.2 http://localhost:8081 — автоматически переводит на страницу keycloak для логина, лигинимся пользоватлем, коотрый создали в KeyCloak:


Супер, всё взлетело, работает!
Что мы сделали для этого? — Добавили несколько строчек кода, всё остальное получаем из коробки Spring Security.
2.5.3 Проверяем открытую сессию пользователя в keycloak сервере:

2.6 Проверяем logout напоследок:


3. Добавляем роли в KeyCloak и проверяем их наличие в приложении для проверки доступа. (https://github.com/ElenaSpb/keycloak-example/pull/2)
При загрузке пользователя в контекст необходимо вычитать из AccessToken роли с KeyCloak и их смапить на допустим свои кастомные в приложении и положить их рядышком в пользователя, чтобы потом из любого места в приложении можно было понять права текущего залогиненого пользователя.
package com.example.keycloak_app.service;import com.example.keycloak_app.auth.LenasOidcUser;
import com.nimbusds.jwt.JWTParser;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;@Service
public class LenasUserService implements OAuth2UserService {
private Map> rolePermissionMap =
Map.of("role1", Set.of("permission1"),
"lenasRole", Set.of("lenasPermission1", "lenasPermission2"),
"lenasGroupRole", Set.of("lenasPermission1", "lenasPermission3")
);
@Override
public OidcUser loadUser(OidcUserRequest oidcUserRequest) throws OAuth2AuthenticationException {
OidcIdToken oidcIdToken = oidcUserRequest.getIdToken();
String email = oidcIdToken.getEmail();
// create user in application repo if it is absent
OAuth2AccessToken accessToken = oidcUserRequest.getAccessToken();
List roles = getRoles(accessToken);
List authorities = roles.stream().map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
Set permissions = getAllPermissions(roles);
return new LenasOidcUser(oidcUserRequest.getIdToken(), authorities, permissions);
}
private List getRoles(OAuth2AccessToken token) {
try {
var realmAccess = (Map) JWTParser.parse(token.getTokenValue())
.getJWTClaimsSet()
.getClaim("realm_access");
ArrayList rolesNode = (ArrayList) realmAccess.get("roles");
return rolesNode.stream().map(Object::toString).collect(Collectors.toList());
} catch (ParseException e) {
throw new AuthenticationServiceException("Error while obtaining user keycloak roles.");
}
}
Set getAllPermissions(List roles) {
// some business logic getting permissions for roles from DB
return roles.stream()
.filter(role -> rolePermissionMap.containsKey(role))
.map(role -> rolePermissionMap.get(role))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
}
4. Решаем вопрос от frontend с проблемой cross-domain redirect при использовании AJAX. (https://github.com/ElenaSpb/keycloak-example/pull/1)
Проблема в том, что Spring под капотом использует несколько редиректов с изменением базы урла, что называется cross-domain redirect problem сейчас в нашем случае это
http://localhost:8081 →
http://localhost:8081/oauth2/authorization/lenas-realm →
http://localhost:8085/realms/lenas-realm/protocol/openid-connect/auth? response_type=code&client_id=lenas-client-id&scope=openid offline_access profile&state=bYkwfiPzHEfjIbnJo75TC7Ea0Una9oJqG-fdYLX_9Uw%3D&redirect_uri=http://localhost:8081/login/oauth2/code/lenas-realm&nonce=nH6OBl3zsjzV6-aFK5csdzr8KfWCsac81hGVe44-Fy8
Причем, если KeyCloak отдельно поднятый сервер, а это так обычно и бывает, например, вместо http://localhost:8085 будет http://keycloak.lenas.com, а смена домена невозможна в нашем случае / небезопасна в общем случае.
Решение: переписываем в этом логине респонс: меняем статус (потому как в тело респонса с 302 боди не вычитывается вообще) и в body складываем url, который берут на строне фронта уже далее и делают переход на этот url своими силами уже.
package com.example.keycloak_app.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.Assert;
import java.io.IOException;
public class LenasDefaultRedirectStrategy implements RedirectStrategy {
protected final Log logger = LogFactory.getLog(this.getClass());
private boolean contextRelative;
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
String redirectUrl = this.calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
if (logger.isDebugEnabled()) {
logger.debug(LogMessage.format("Redirecting to %s", redirectUrl));
}
// for first AUTH redirect on {host}/Intra/oauth2/authorization/keycloak
// to fix ajax cross-domain FE issue about impossible redirect by 302 http status
if (redirectUrl.contains("/oauth2/authorization/lenas-realm")) {
response.setStatus(401);
response.getWriter().write(redirectUrl);
response.flushBuffer();
} else {
// for all other redirections should by default
response.sendRedirect(redirectUrl);
}
}
protected String calculateRedirectUrl(String contextPath, String url) {
if (!UrlUtils.isAbsoluteUrl(url)) {
return this.isContextRelative() ? url : contextPath + url;
} else if (!this.isContextRelative()) {
return url;
} else {
Assert.isTrue(url.contains(contextPath), "The fully qualified URL does not include context path.");
url = url.substring(url.lastIndexOf("://") + 3);
url = url.substring(url.indexOf(contextPath) + contextPath.length());
if (url.length() > 1 && url.charAt(0) == '/') {
url = url.substring(1);
}
return url;
}
}
public void setContextRelative(boolean useRelativeContext) {
this.contextRelative = useRelativeContext;
}
protected boolean isContextRelative() {
return this.contextRelative;
}
}
Этот класс переписан вместо спрингового DefaultRedirectStrategy (добавлена только одна ветка проверки, что у нас 1й редирект). Эта стратегия является приватным финальным полем класса LoginUrlAuthenticationEntryPoint, откуда и идет этот 1й редирект.
Оба эти класса причем не являются спринговыми бинами, чтобы их подменить просто.
Зато LoginUrlAuthenticationEntryPoint является полем, наконец уже, бина DelegatingAuthenticationEntryPoint.
Его и будем переписывать. Чтобы изменить это поле, пришлось написать свой BeanPostProcessor с установкой своей кастомной стратегии редиректа в поле поля бина:
package com.example.keycloak_app.auth;import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.stereotype.Component;import java.lang.reflect.Field;
import java.util.LinkedHashMap;
@Component
public class LenasChangeLoginRedirectStrategyBeanPostProcessor implements BeanPostProcessor {
/**
set private final redirect strategy to use custom 401 http status instead of 302
to solve FE issue about impossible cross-domain redirect with 302 http status.
due to https://github.com/spring-projects/spring-security/pull/11387
set redirectStrategy in SpringSecurity can be done after Spring 5.8
todo: delete this class after update Spring and setting IntraDefaultRedirectStrategy in SecurityConfig
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean.getClass() == DelegatingAuthenticationEntryPoint.class) {
Field entryPoints = DelegatingAuthenticationEntryPoint.class.getDeclaredField("entryPoints");
entryPoints.setAccessible(true);
LinkedHashMap map = null;
map = (LinkedHashMap) entryPoints.get(bean);
var loginUrlAuthenticationEntryPoint = map.values().stream().findFirst().get();
LenasDefaultRedirectStrategy lenasDefaultRedirectStrategy = new LenasDefaultRedirectStrategy();
setRedirectStrategy(loginUrlAuthenticationEntryPoint, lenasDefaultRedirectStrategy);
}
return BeanPostProcessor.super.postProcessAfterInitialization(bean, beanName);
}
private void setRedirectStrategy(Object object, Object redirectStrategy) throws NoSuchFieldException, IllegalAccessException {
Field field = object.getClass().getDeclaredField("redirectStrategy");
field.setAccessible(true);
field.set(object, redirectStrategy);
}
}
Итог: теперь при первом редиректе получаем. Из response body берем url, мы для тестирования руками, на фронте эта обработка реализуется в обход ajax redirect, и по нему продолжаем процесс логина:

Далее переходим по этому url и продолжаем обычный процесс редиректа:

5. Тестирование
Для более удобного тестирования я написла свою аннатацию, чтобы сразу в ней можно было бы задавать permissions мокируемого пользователя, который проставляется к security-context, который также мокируется.
package com.example.keycloak_app.auth;import org.springframework.security.test.context.support.WithSecurityContext;import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockLenasUserSecurityContextFactory.class)
public @interface WithMockLenasUser {
String name() default "Test User";
String permissions() default "some";
}
Ну и класс фабрики для обработки этой аннотации:
package com.example.keycloak_app.auth;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
import org.springframework.security.test.context.support.WithSecurityContextFactory;
import java.util.Set;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class WithMockLenasUserSecurityContextFactory implements WithSecurityContextFactory {
@Override
public SecurityContext createSecurityContext(WithMockLenasUser customUser) {
var permissions = Set.of(customUser.permissions().replaceAll(" ", "").split(","));
var lenasOidcUser = mock(LenasOidcUser.class);
when(lenasOidcUser.getAllPermissions()).thenReturn(permissions);
var authentication = mock(OAuth2LoginAuthenticationToken.class);
when(authentication.getPrincipal()).thenReturn(lenasOidcUser);
when(authentication.isAuthenticated()).thenReturn(true);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
}
Далее эту аннотацию @WithMockLenasUser можно просто использовать в тестах контроллеров енд-пойнтов, доступ к которым возможен только залогиненым пользователям и в тестах сервисов, где используется проверка текущего пользоватлея в сессии и возможно его права.
Ура, Конец и всё работает!
Да, решения некоторые не такие идеальные, как хотелось бы, но данный вариант рабочий и это главное для 1й итерации.
Есть интенция некоторые костыли переписать, даст Бог, будет ресурс это сделать.
В частности подумать, как можно было бы улучшить вариант решения с редиректами на 401 (401 — это запрос с фронта).
Ну и проработать еще раз HttpSecurity, такое чувство, что с каждой новой версией Spring приходится переписывать эту часть, примеры, на которые ориентируемся устаревают.
Как на новой версии Spring Security 6.4 переписать правильно эту часть пока не нашла.
Если Вы сталкивались с подобными кейсами и придумали/нашли решение получше, пожалуйста, напишите в комментарии, буду благодарна.