Контекст безопасности с HttpSessionSecurityContextRepository всегда возвращает 403 после успешной аутентификации, Spring Boot 3.3

Я пересматриваю и изучаю Spring Boot. В последней версии 3.3 возникают проблемы с сохранением контекста безопасности в сеансе.

Я пытаюсь создать обычный вход в систему на основе сеанса на стороне сервера с интерфейсом JavaScript/React. Поэтому я хочу иметь возможность входить в систему с помощью JSON из браузера, но затем легко использовать файлы cookie. Вроде все нормально, аутентификация работает, но при последующих запросах получаю 403 на каждый защищенный маршрут. Я использую Postman, так как это макетный проект, позже у меня будет свой фронтенд.

Я практикую ручную аутентификацию в конечной точке Rest Controller, поэтому могу отправить имя пользователя и пароль в формате JSON.

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

Ниже приведен весь код и результаты отладки.

О настойчивости я узнал здесь: https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html

И последовал примеру здесь: https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html#understanding-session-management-comComponents

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

Существует не так уж много другой документации о том, как с этим справиться, другие ответы здесь кажутся очень старыми и неприменимыми к Spring boot 3.1.

Вот весь код:

Основная конфигурация безопасности:

@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private MuserDetailsService muserDetailsService;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        /**
         * Since I am using method security,
         * I do not need to do too many customizer requests here,
         * But for the basic ones, it seems this is important ?
         * Check notes
         */
        http.authorizeHttpRequests(customizer -> {
            customizer
                .requestMatchers("/", "/public" , "/login", "/all-methods").permitAll()
                .requestMatchers(HttpMethod.GET, "/csrf/latest").permitAll();
        });

        /**
         * CSRF can be disbaled like so:
         */
//      http.csrf((csrf) -> {
//          csrf.disable();
//      });

        /**
         * Adding the custom filters to deal with the filter chain,
         * This filter is added after the CsrfFilter so I can access it
         */
        //http.addFilterAfter(new MyCsrfTokenLazyLoadFilter(), CsrfFilter.class);

        /**
         * Here I am adding the security context repository
         */
//      http.securityContext(context -> {
//          context.requireExplicitSave(true);
//      });

        /**
         * This returns the Security filter chain
         */
        return http.build();
    }

    /**
     * Here i am publishing my AuthenticationManager Bean,
     * It is important to do this in order to secure Api's
     */
    @Bean
    public AuthenticationManager getAuthenticationManager() throws Exception {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(muserDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(authProvider);
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
        return roleHierarchy;
    }

    @Bean
    public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy());
        return expressionHandler;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }


}

Создание сеансовых компонентов:

@Configuration
public class SessionBeanConfig {

    /**
     * This does not need to be created as a Bean,
     * I could instantiate it directly in the controller,
     * But I am just doing it here, this will be used to 
     * manually create a session,
     */
    @Bean
    public HttpSessionSecurityContextRepository getSecurityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    public SecurityContextHolderStrategy getSecurityContextHolderStrategy() {
        return SecurityContextHolder.getContextHolderStrategy();
    }

}

API входа в систему:

@RestController
public class LoginApi {

    @Autowired
    private HttpSessionSecurityContextRepository repo;

    @Autowired
    private SecurityContextHolderStrategy securityContextHolderStrategy;

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * A simple login request that takes the login and responds with the same info and a message
     * This works, the only issue here is persistence between requests using a regular session
     * @return
     */
    @PostMapping("/login")
    public LoginResponse loginPost(
        @RequestBody LoginRequest loginRequest,
        HttpServletRequest request,
        HttpServletResponse response
     ) {

        Authentication authentication = new UsernamePasswordAuthenticationToken(
            loginRequest.username(),
            loginRequest.password()
        );

        Authentication authenticated = authenticationManager.authenticate(authentication);

        //First I create the empty context after authentication
        SecurityContext context = securityContextHolderStrategy.createEmptyContext();

        //Add the authenticated token to the security context
        context.setAuthentication(authenticated);

        //Add the whole context to the context holder strategy instead of regular SecurityContextHolder
        securityContextHolderStrategy.setContext(context);

        //Now i save the context in the session, needs to be done explicitly in this scenario:
        repo.saveContext(context, request, response);

        

        //Return the new LoginResponse
        return new LoginResponse(
            loginRequest.username(),
            loginRequest.password(),
            true,
            "Login successful!",
            authenticated,
            null
        );
    }

}

Записи, представляющие запрос/ответ

public record LoginRequest(
    String username,
    String password
) {
}

public record LoginResponse(
    String username,
    String password,
    Boolean result,
    String message,
    Authentication token,
    SecurityContext securityContext
) {
}

Предоставление конечной точки CSRF для проверки почтальоном:

@RestController
@RequestMapping("/csrf")
public class CsrfApi {

    @GetMapping("/latest")
    public CsrfToken getLatest(CsrfToken token) {
        return token;
    }

}

Конечные точки API для тестирования:

@RestController конечные точки публичного класса {

@GetMapping("/")
public String index() {
    return "Welcome!";
}

@GetMapping("/public")
public String publicIndex() {
    return "Public!";
}

@GetMapping("/private")
@PreAuthorize("isAuthenticated()")
public String privateIndex() {
    return "Private!";
}

@RequestMapping("/all-methods")
public String allMethods() {
    return "All Methods";
}

}

@RestController
@RequestMapping("/child")
@PreAuthorize("hasRole('CHILD')")
public class ChildApi {

    @RequestMapping("")

    public String index() {
        return "Child Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('CHILD_CREATE')")
    public String create() {
        return "Child Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('CHILD_READ')")
    public String read() {
        return "Child Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('CHILD_UPDATE')")
    public String update() {
        return "Child Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('CHILD_DELETE')")
    public String delete() {
        return "Child Api deleted";
    }

}

@RestController
@RequestMapping("/parent")
@PreAuthorize("hasRole('PARENT')")
public class ParentApi {

    @RequestMapping("")
    public String index() {
        return "Parent Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('PARENT_CREATE')")
    public String create() {
        return "Parent Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('PARENT_READ')")
    public String read() {
        return "Parent Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('PARENT_UPDATE')")
    public String update() {
        return "Parent Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('PARENT_DELETE')")
    public String delete() {
        return "Parent Api deleted";
    }

}

@RestController
@RequestMapping("/gp")
@PreAuthorize("hasRole('ROLE_GRANDPARENT')")
public class GrandParentApi {

    @RequestMapping("")
    public String index() {
        return "Grand parent Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('GRANDPARENT_CREATE')")
    public String create() {
        return "GrandParent Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('GRANDPARENT_READ')")
    public String read() {
        return "GrandParent Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('GRANDPARENT_UPDATE')")
    public String update() {
        return "GrandParent Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('GRANDPARENT_DELETE')")
    public String delete() {
        return "GrandParent Api deleted";
    }

}

@RestController
@RequestMapping("/mythical")
@PreAuthorize("hasRole('ROLE_MYTHICAL_USER')")
public class MythicalApi {

    @GetMapping("")
    public String index() {
        return "Mythical User Home!";
    }

    @GetMapping("/create")
    public String create() {
        return "Mythical User Create!";
    }

    @GetMapping("/read")
    public String read() {
        return "Mythical User Read!";
    }

    @GetMapping("/update")
    public String update() {
        return "Mythical User Update!";
    }

    @GetMapping("/delete")
    public String delete() {
        return "Mythical User Delete!";
    }
}

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

Здесь я показываю некоторые результаты:

В журнале нет вывода или трассировки:


Получен запрос на GET '/private':

org.apache.catalina.connector.RequestFacade@2add0c0d

servletPath:/частный путьИнформация: нуль заголовки: пользовательский агент: PostmanRuntime/7.39.0 принимать: / контроль кэша: без кэша токен почтальона: 80036f66-c518-4e44-8557-cc99aa43c3b4 хост: локальный хост: 8080 принять-кодировку: gzip, deflate, br соединение: поддержание активности файл cookie: JSESSIONID=3EABB2B10BF2079416CADD3ED85BDBB0

Цепочка фильтров безопасности: [ ОтключитьEncodeUrlFilter Вебасинкманажеринтегратионфилтер SecurityContextHolderFilter ЗаголовокПисательФильтр CorsFilter CsrfFilter Фильтр выхода из системы RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter Фильтр анонимной аутентификации Фильтр перевода исключений Фильтр авторизации ]


2024-07-06T22:44:53.137+01:00 INFO 3618 --- [RestApiAuthentication] [nio-8080-exec-5] Отладчик Spring Security:


Получен запрос на GET '/error':

org.apache.catalina.core.ApplicationHttpRequest@7a475766

Путь сервлета:/ошибка путьИнформация: нуль заголовки: пользовательский агент: PostmanRuntime/7.39.0 принимать: / контроль кэша: без кэша токен почтальона: 80036f66-c518-4e44-8557-cc99aa43c3b4 хост: локальный хост: 8080 принять-кодировку: gzip, deflate, br соединение: поддержание активности файл cookie: JSESSIONID=3EABB2B10BF2079416CADD3ED85BDBB0

Цепочка фильтров безопасности: [ ОтключитьEncodeUrlFilter Вебасинкманажеринтегратионфилтер SecurityContextHolderFilter ЗаголовокПисательФильтр CorsFilter CsrfFilter Фильтр выхода из системы RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter Фильтр анонимной аутентификации Фильтр перевода исключений Фильтр авторизации ]


Если я выполняю отладку, я вижу, что SecurityContext всегда имеет значение NULL в классе HttpSessionRepository.

Любой совет будет принят с благодарностью

Я не уверен, почему я получаю 403

Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Версия Java на основе версии загрузки
Версия Java на основе версии загрузки
Если вы зайдете на официальный сайт Spring Boot , там представлен start.spring.io , который упрощает создание проектов Spring Boot, как показано ниже.
Документирование API с помощью Swagger на Springboot
Документирование API с помощью Swagger на Springboot
В предыдущей статье мы уже узнали, как создать Rest API с помощью Springboot и MySql .
1
0
63
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Мне пришлось отлаживать всю цепочку фильтров, чтобы понять, что произошло.

Аутентификация прошла идеально, но проблема была с авторизацией.

В основном потому, что в файле конфигурации была определена авторизация на уровне метода, а также средства сопоставления запросов. Фильтр авторизации обрабатывал только средства сопоставления запросов из файла конфигурации. В этом примере было определено только пять маршрутов: /, /login, /all-methods, /public, /csrf/latest. Как только я удалил сопоставители запросов, все работает нормально.

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

Только в последнем фильтре, который является фильтром авторизации, было выброшено исключение AccesDeniedException.

После отладки кода в этом фильтре я наконец понял это.

Вот часть кода:

@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private MuserDetailsService muserDetailsService;

    @Autowired
    private MySecurityContextLogFilter mySecurityContextLogFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        /**
         * This is a filter that logs the security context,
         * Shows the security context is correct before the last filter in the
         * filter chain
         */
        //http.addFilterAfter(new MyCsrfTokenLazyLoadFilter(), CsrfFilter.class);
        http.addFilterBefore(mySecurityContextLogFilter, AuthorizationFilter.class);

        return http.build();
    }

    /**
     * Here i am publishing my AuthenticationManager Bean,
     * It is important to do this in order to secure Api's
     */
    @Bean
    public AuthenticationManager getAuthenticationManager() throws Exception {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(muserDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(authProvider);
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
        return roleHierarchy;
    }

    @Bean
    public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy());
        return expressionHandler;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

Класс фильтра, который регистрирует контекст безопасности:

@Component
@Slf4j
public class MySecurityContextLogFilter extends OncePerRequestFilter {

    @Autowired
    private HttpSessionSecurityContextRepository securityContextRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("MySecurityContextCheckerFilter doFilterInternal");
        log.info("SecurityContext from repository exists? : " + securityContextRepository
                .containsContext(request)
        );

        log.info("Authentication object: " +
            securityContextRepository.loadDeferredContext(request)
                .get()
                .getAuthentication()
            );

        filterChain.doFilter(request, response);
    }
}

Здесь я вижу фильтр, регистрирующий контекст безопасности как аутентифицированный:

Во время отладки я обнаружил проблему в методе doFilter AuthorizationFilter.java, Было выдано исключение отказа в доступе

Теперь я вижу, что результаты работают отлично:

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

Даже иерархия ролей теперь работает отлично:

@Override public void doFilter (ServletRequest servletRequest, ServletResponse servletResponse, цепочка FilterChain) выдает ServletException, IOException {

    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse) servletResponse;

    if (this.observeOncePerRequest && isApplied(request)) {
        chain.doFilter(request, response);
        return;
    }

    if (skipDispatch(request)) {
        chain.doFilter(request, response);
        return;
    }

    String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
    request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
    try {
        AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
        this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
        if (decision != null && !decision.isGranted()) {
            throw new AccessDeniedException("Access Denied");
        }
        chain.doFilter(request, response);
    }
    finally {
        request.removeAttribute(alreadyFilteredAttributeName);
    }
}

401=Аутентификация, 403=Авторизация... не всегда верно :)

Dan Chase 08.07.2024 03:14

Другие вопросы по теме