Я пересматриваю и изучаю 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
Мне пришлось отлаживать всю цепочку фильтров, чтобы понять, что произошло.
Аутентификация прошла идеально, но проблема была с авторизацией.
В основном потому, что в файле конфигурации была определена авторизация на уровне метода, а также средства сопоставления запросов. Фильтр авторизации обрабатывал только средства сопоставления запросов из файла конфигурации. В этом примере было определено только пять маршрутов: /, /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=Авторизация... не всегда верно :)