Разработка приложения с авторизацией пользователя 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.
Вот логи:

Логи сервера Keycloak при первом запуске и создании temporary admin user.
Логи сервера Keycloak при первом запуске и создании temporary admin user.

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

cc2bdf019822eabae84067822e135cb5.png


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

Создание клиента 1 шаг
Создание клиента 1 шаг
Создание клиента 2 шаг
Создание клиента 2 шаг
Создание клиента 3 шаг
Создание клиента 3 шаг

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

Создание пользователя 1 шаг
Создание пользователя 1 шаг
Создание пользователя: задание пароля
Создание пользователя: задание пароля


2. Разработка Java приложения

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

b55e1dddbf32fbf4b2b8a69f2796c8aa.png

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 берем отсюда:

432bfc162541a28c6900f247be3931cb.png

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 сервере:

Сессии пользователей в Keycloak
Сессии пользователей в Keycloak


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

Logout из коробки
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, и по нему продолжаем процесс логина:

1й редирект дает вместо 302 теперь 401 статус с url-ом для перехода в теле запроса
1й редирект дает вместо 302 теперь 401 статус с url-ом для перехода в теле запроса

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

2й редирект уже на сам логин на сервере Keycloak
2й редирект уже на сам логин на сервере Keycloak


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 переписать правильно эту часть пока не нашла.

Если Вы сталкивались с подобными кейсами и придумали/нашли решение получше, пожалуйста, напишите в комментарии, буду благодарна.

© Habrahabr.ru