Путь запроса по внутренностям Spring Security +22


Большинство разработчиков имеет только примерное представление о том что происходит внутри Spring Security, что опасно и может привести к появлению уязвимостей.

В этой статье шаг за шагом пройдемся по пути http запроса, что поможет с пониманием настраивать и решать проблемы Spring Security.

image


Подготовка проекта


Для начала подготовим проект, зайдем на https://start.spring.io/, поставим галочки напротив Web > web, и Core > Security.

Добавим контролер:

@RestController
public class Controller {

  @GetMapping
  public String get() {
    return String.valueOf(System.currentTimeMillis());
  }
}

Добавим rest-assured:

testCompile('io.rest-assured:rest-assured:3.0.2')

Добавим груви:

apply plugin: 'groovy'

Напишем тест:

ControllerIT.groovy

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = "security.user.password=pass")
class ControllerIT {

  @LocalServerPort
  private int serverPort;

  @Before
  void initRestAssured() {
    RestAssured.port = serverPort;
    RestAssured.filters(new ResponseLoggingFilter());
    RestAssured.filters(new RequestLoggingFilter());
  }

  @Test
  void 'api call without authentication must fail'() {
    when()
      .get("/")
    .then()
      .statusCode(HttpStatus.SC_UNAUTHORIZED);
  }
}

Запустим тест. Что в логах?

Запрос:

Request method:  GET
Request URI:  http://localhost:51213/

Ответ:

HTTP/1.1 401 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
WWW-Authenticate: Basic realm="Spring"
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 22 Oct 2017 11:53:00 GMT

{
  "timestamp": 1508673180745,
  "status": 401,
  "error": "Unauthorized",
  "message": "Full authentication is required to access this resource",
  "path": "/"
}

SS без дополнительных настроек уже начал защищать вызовы методов, так как заработала конфигурация — SpringBootWebSecurityConfiguration поставляемая spring boot-ом. Внутри этого класса лежит ApplicationNoWebSecurityConfigurerAdapter который устанавливает дефолты.

На некоторые из них можно повлиять через настройки:
docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
искать "# SECURITY PROPERTIES", также можно заглянуть в код: SecurityProperties

Донастроим spring boot конфигурацию для полноты истории:

@TestPropertySource(properties = [
    "security.user.password=pass",
    "security.enable-csrf=true",
    "security.sessions=if_required"
])

Фильтры


Работа Spring Security в веб приложении начинается с servlet фильтра.
Попробуем подебажить, но для начала добавим тест с успешной авторизацией.

@Test
void 'api call with authentication must succeed'() {
  given()
    .auth().preemptive().basic("user", "pass")
  .when()
    .get("/")
  .then()
    .statusCode(HttpStatus.SC_OK);
}

Поставим бряк и запустим тест.


рис. 1 — get метод

спустимся по огромному стеку вызовов (new Exception().getStackTrace().length == 91) и найдем первое упоминание спринга


рис. 2 — стэк вызовов

Посмотрим что лежит в переменной filterChain


рис. 3 — application filter chain

Здесь интересен фильтр springSecurityFilterChain именно он делает всю работу SS в веб части.

Сам DelegatingFilterProxyRegistrationBean не очень интересен, посмотрим кому он делегирует свою работу


рис. 4 — filter chain proxy

Свою работу он делегирует классу FilterChainProxy. Внутри него происходит несколько интересных вещей.

Прежде всего посмотрим на метод FilterChainProxy#doFilterInternal. Что здесь происходит? Получаем фильтры, создаем VirtualFilterChain и запускаем по ним запрос и ответ.

List<Filter> filters = getFilters(fwRequest);
...
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);

Внутри метода getFilters берем первый SecurityFilterChain который совпадет с запросом.

private List<Filter> getFilters(HttpServletRequest request) {
  for (SecurityFilterChain chain : filterChains) {
    if (chain.matches(request)) {
      return chain.getFilters();
    }
  }
  return null;
}

Зайдем в деббагер и посмотрим какой список итерируется.


рис. 5 — security filter chains

Что нам говорит этот список?

В обоих листах лежит OrRequestMatcher, который попытается сматчить текущий url с хотя бы с одним паттерном из списка.

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

Проверим на практике.
Любой url который совпадет с этим паттернами, по умолчанию не будет защищен SS.
"/css/**", "/js/**", "/images/**", "/webjars/**", "/**/favicon.ico", "/error"
Добавим метод:


@GetMapping("css/hello")
public String cssHello() {
  return "Hello I'm secret data";
}

Напишем тест:


@Test
void 'get css/hello must succeed'() {
  when()
    .get("css/hello")
  .then()
    .statusCode(HttpStatus.SC_OK);
}

Гораздо интереснее второй SecurityFilterChain который совпадет с любым url "/**"

В нашем случае имеется следующий список фильтров.

0 = {WebAsyncManagerIntegrationFilter} 
1 = {SecurityContextPersistenceFilter} 
2 = {HeaderWriterFilter} 
3 = {CsrfFilter} 
4 = {LogoutFilter} 
5 = {BasicAuthenticationFilter} 
6 = {RequestCacheAwareFilter} 
7 = {SecurityContextHolderAwareRequestFilter} 
8 = {AnonymousAuthenticationFilter} 
9 = {SessionManagementFilter} 
10 = {ExceptionTranslationFilter} 
11 = {FilterSecurityInterceptor}

Этот список может изменяться в зависимости от настроек и добавленных зависимостей.
Например с такой конфигурацией:


http
  .authorizeRequests().anyRequest().authenticated()
.and()
  .formLogin()
.and()
  .httpBasic();
	

В этот список добавились бы фильтры:

UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter

В каком порядке фильтры идут по дефолту можно посмотреть здесь: FilterComparator

0 = {WebAsyncManagerIntegrationFilter}


Нам не очень интересен, согласно документации он «интегрирует» SecurityContext с WebAsyncManager который отвественнен за асинхронные запросы.

1 = {SecurityContextPersistenceFilter}


Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находит.
По умолчанию используется ThreadLocalSecurityContextHolderStrategy которая хранит SecurityContext в ThreadLocal переменной.

2 = {HeaderWriterFilter}


Просто добавляет заголовки в response.

Отключаем кэш:

– Cache-Control: no-cache, no-store, max-age=0, must-revalidate
– Pragma: no-cache
– Expires: 0

Не разрешаем браузерам автоматически определять тип контента:

– X-Content-Type-Options: nosnif

Не разрешаем iframe

– X-Frame-Options: DENY

Включаем встроенную зашиту в браузер от cross-site scripting (XSS)

– X-XSS-Protection: 1; mode=block

3 = {CsrfFilter}


Пожалуй нет ни одного разработчика который при знакомстве с SS не столкнулся бы с ошибкой «отсутсвия csrf токена».

Почему мы не встречали эту ошибку ранее? Все просто, мы запускали методы на которых нет csrf защиты.

Попробуем добавить POST метод

  
@PostMapping("post")
  public String testPost() {
    return "Hello it is post request";
  }

Тест:


@Test
void 'POST without CSRF token must return 403'() {
  given()
    .auth().preemptive().basic("user", "pass")
  .when()
    .post("/post")
  .then()
    .statusCode(HttpStatus.SC_FORBIDDEN);
}

Тест выполнился успешно, нам вернули 403 ошибку, csrf защита на месте.

4 = {LogoutFilter}


Далее идет logout фильтр, он проверяет совпадает ли url c паттерном
Ant [pattern='/logout', POST] - по умолчанию
и запускает процедуру логаута

handler = {CompositeLogoutHandler} 
 logoutHandlers = {ArrayList}  size = 2
  0 = {CsrfLogoutHandler} 
  1 = {SecurityContextLogoutHandler}

по дефолту происходит следующие:

  1. Удаляется Csrf токен.
  2. Завершается сессия
  3. Чистится SecurityContextHolder

5 = {BasicAuthenticationFilter}


Теперь мы добрались непосредственно до аутентификации. Что происходит внутри?
Фильтр проверяет, есть ли заголовок Authorization со значением начинающийся на Basic
Если находит, извлекает логин\пароль и передает их в AuthenticationManager

Внутри примерно такой код:


if (headers.get("Authorization").startsWith("Basic")) {
  try {
  UsernamePasswordAuthenticationToken token = extract(header);
  Authentication authResult = authenticationManager.authenticate(token);
  
  } catch (AuthenticationException failed) {
  SecurityContextHolder.clearContext();
  this.authenticationEntryPoint.commence(request, response, failed);
  return;
  }
} else {
  chain.doFilter(request, response);
}

AuthenticationManager



public interface AuthenticationManager {
  Authentication authenticate(Authentication authentication) 
      throws AuthenticationException;
}

AuthenticationManager представляет из себя интрефейс, который принимает Authentication и возвращает тоже Authentication.

В нашем случае в имплементацией Authentication будет UsernamePasswordAuthenticationToken.
Можно было бы реализовать AuthenticationManager самому, но смысла в этом мало, существует дефолтная реализация — ProviderManager.

ProviderManager авторизацию делегирует другому интерфейсу:


public interface AuthenticationProvider {
  Authentication authenticate(Authentication authentication) 
      throws AuthenticationException;
  boolean supports(Class<?> authentication);
}

Когда мы передаем объект Authentication в ProviderManager, он перебирает существующие AuthenticationProvider-ры и проверяет суппортит ли
AuthenticationProvider эту имплементацию Authentication


public boolean supports(Class<?> authentication) {
  return (UsernamePasswordAuthenticationToken.class
          .isAssignableFrom(authentication));
}

В результате внутри AuthenticationProvider.authenticate мы уже можем скастить переданный Authentication в нужную реализацию без каст эксепшена.

Далее из конкретной реализации вытаскиваем креденшеналы.

Если аутентификация не удалась AuthenticationProvider должен бросить эксепшен, ProviderManager поймает его и попробует следующий AuthenticationProvider из списка, если ни один AuthenticationProvider не вернет успешную аутентификацию, то ProviderManager пробросит последний пойманный эксепшен.

Более подробно и с картинками процесс описан здесь:
https://spring.io/guides/topicals/spring-security-architecture/

Далее BasicAuthenticationFilter сохраняет полученный Authentication в SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authResult);
Процесс аутентификации на этом завершен.

Если выбросится AuthenticationException то будет сброшен SecurityContextHolder.clearContext(); контекст и вызовится AuthenticationEntryPoint.


public interface AuthenticationEntryPoint {
  void commence(HttpServletRequest request, 
               HttpServletResponse response,
               AuthenticationException authException)  throws IOException, ServletException;
}

Задачей AuthenticationEntryPoint явялется записать в ответ информацию о том что аутентификация не удалась.

В случае бейсик аутентификации это будет:


response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());

В результате браузер покажет окошко basic авторизации.

6 = {RequestCacheAwareFilter}


Для чего нужен этот фильтр? Представим сценарий:

1. Пользователь заходит на защишенный url.
2. Его перекидывает на страницу логина.
3. После успешной авторизации пользователя перекидывает на страницу которую он запрашивал в начале.

Именно для для восстановления оригинального запроса существует этот фильтр.
Внутри проверяется есть ли сохраненный запрос, если есть им подменяется текущий запрос.
Запрос сохраняется в сессии, на каком этапе он сохраняется будет написанно ниже.

Попробуем вопроизвести.

Добавим метод:


@GetMapping("customHeader")
public String customHeader(@RequestHeader("x-custom-header") String customHeader) {
  return customHeader;
}

Добавим тест:

@Test
void 'passed x-custom-header must be returned'() {
  def sessionCookie = given()
      .header("x-custom-header", "hello")
    .when()
      .get("customHeader")
    .then()
      .statusCode(HttpStatus.SC_UNAUTHORIZED)
      .extract().cookie("JSESSIONID")

  given()
      .auth().basic("user", "pass")
      .cookie("JSESSIONID", sessionCookie)
  .when()
      .get("customHeader")
  .then()
      .statusCode(HttpStatus.SC_OK)
      .body(equalTo("hello"));
}

Как видим во втором запросе нам вернулся заголовок который мы передали в первом запросе. Фильтр работает.

7 = {SecurityContextHolderAwareRequestFilter}


Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapper


chain.doFilter(this.requestFactory.create((HttpServletRequest) req, (HttpServletResponse) res), res);

Имплементация может отличаться в зависимости от servlet api версии servlet 2.5/3

8 = {AnonymousAuthenticationFilter}


Если к моменту выполнения этого фильтра SecurityContextHolder пуст, т.е. не произошло аутентификации фильтр заполняет объект SecurityContextHolder анонимной аутентификацией — AnonymousAuthenticationToken с ролью «ROLE_ANONYMOUS».

Это гарарантирует что в SecurityContextHolder будет объект, это позволяет не бояться NP, а также более гибко подходить к настройке доступа для неавторизованных пользователей.

9 = {SessionManagementFilter}


На это этапе производятся действия связанные с сессией.

Это может быть:

— смена идентификатора сессии
— ограничени количества одновременных сессий
— сохранение SecurityContext в securityContextRepository

В нашем случае происходит следующе:
SecurityContextRepository с дефолтной реализацией HttpSessionSecurityContextRepository сохраняет SecurityContext в сессию.
Вызывается sessionAuthenticationStrategy.onAuthentication

Внутри sessionAuthenticationStrategy лежит:

sessionAuthenticationStrategy = {CompositeSessionAuthenticationStrategy}
 delegateStrategies
  0 = {ChangeSessionIdAuthenticationStrategy} 
  1 = {CsrfAuthenticationStrategy}

Происходят 2 вещи:

1. По умолчанию включенна защита от session fixation attack, т.е. после аутенцификации меняется id сессии.
2. Если был передан csrf токен, генерируется новый csrf токен

Попробуем проверить первый пункт:


@Test
void 'JSESSIONID must be changed after login'() {
   def sessionCookie = when()
      .get("/")
  .then()
      .statusCode(HttpStatus.SC_UNAUTHORIZED)
      .extract().cookie("JSESSIONID")

  def newCookie = given()
      .auth().basic("user", "pass")
      .cookie("JSESSIONID", sessionCookie)
  .when()
      .get("/")
  .then()
      .statusCode(HttpStatus.SC_OK)
      .extract().cookie("JSESSIONID")

  Assert.assertNotEquals(sessionCookie, newCookie)
}

10 = {ExceptionTranslationFilter}


К этому моменту SecurityContext должен содеражть анонимную, либо нормальную аутентификацию.

ExceptionTranslationFilter прокидывает запрос и ответ по filter chain и обрабатывает возможные ошибки авторизации.

SS различает 2 случая:

1. AuthenticationException
Вызывается sendStartAuthentication, внутри которого происходит следующиее:

SecurityContextHolder.getContext().setAuthentication(null); — отчищает SecurityContextHolder
requestCache.saveRequest(request, response); — сохраняет в requestCache текущий запрос, чтобы RequestCacheAwareFilter было что восстанавливать.
authenticationEntryPoint.commence(request, response, reason); — вызывает authenticationEntryPoint — который записывает в ответ сигнал о том что необходимо произвести аутентификацию (заголовки \ редирект)

2. AccessDeniedException

Тут опять возможны 2 случая:


if (authenticationTrustResolver.isAnonymous(authentication) || 
  authenticationTrustResolver.isRememberMe(authentication)) {
  ...
} else {
 ...
}

1. Пользователь с анонимной аутентификацией, или с аутентификацией по rememberMe токену
вызывается sendStartAuthentication

2. Пользователь с полной, не анонимной аутентификацией вызывается:
accessDeniedHandler.handle(request, response, (AccessDeniedException) exception)
который по дефолту проставляет ответ forbidden 403

11 = {FilterSecurityInterceptor}


На последнем этапе происходит авторизация на основе url запроса.
FilterSecurityInterceptor наследуется от AbstractSecurityInterceptor и решает, имеет ли текущий пользователь доступ до текущего url.

Существует другая реализация MethodSecurityInterceptor который отвественнен за допуск до вызова метода, при использовании аннотаций @Secured\@PreAuthorize.

Внутри вызывается AccessDecisionManager

Есть несколько стратегий принятия решения о том давать ли допуск или нет, по умолчанию используется: AffirmativeBased

код внутри очень простой:


for (AccessDecisionVoter voter : getDecisionVoters()) {
  int result = voter.vote(authentication, object, configAttributes);
  switch (result) {
  case AccessDecisionVoter.ACCESS_GRANTED:
    return;
  case AccessDecisionVoter.ACCESS_DENIED:
    deny++;
    break;
  default:
    break;
  }
}
if (deny > 0) {
  throw new AccessDeniedException();
}
checkAllowIfAllAbstainDecisions();

Иными словами если кто-то голосует за, пропускаем, если хоть 1 голосует против не пускаем, если никто не проголосовал не пускаем.

Подведем небольшой итог:

springSecurityFilterChain — набор фильтров spring security.

Пример набора фильтров для basic авторизации:

WebAsyncManagerIntegrationFilter — Интегрирует SecurityContext с WebAsyncManager
SecurityContextPersistenceFilter — Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находит
HeaderWriterFilter — Добавляет «security» заголовки в ответ
CsrfFilter — Проверяет на наличие сsrf токена
LogoutFilter — Выполняет logout
BasicAuthenticationFilter — Производит basic аутентификацию
RequestCacheAwareFilter — Восстанавливает сохраненный до аутентификации запрос, если такой есть
SecurityContextHolderAwareRequestFilter — Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapper
AnonymousAuthenticationFilter — Заполняет SecurityContext ананонимной аутентификацией
SessionManagementFilter — Выполняет действия связанные с сессией
ExceptionTranslationFilter — Обрабатывает AuthenticationException\AccessDeniedException которые происходят ниже по стеку.
FilterSecurityInterceptor — Проверяет имеет ли текущей пользователь доступ к текущему url.

FilterComparator — здесь можно посмотреть список фильтров и их возможный порядок.

AuthenticationManager — интерфейс, ответственнен за аутентификацию
ProviderManager — реализация AuthenticationManager, которая использует внутри использует AuthenticationProvider
AuthenticationProvider — интерфейс, отвественнен за аутентификаци конкретной реализации Authentication.
SecurityContextHolder — хранит в себе аутентификацию обычно в ThreadLocal переменной.
AuthenticationEntryPoint — модифицирует ответ, чтобы дать понять клиенту что необходима аутентификация (заголовки, редирект на страницу логина, т.п.)

AccessDecisionManager решает имеет ли Authentication доступ к какому-то ресурсу.
AffirmativeBased — стратегия используемая AccessDecisionManager по умолчанию.

Рекомендации


Напишите простые тесты, которые протестируют порядок фильтров и их настройки


это позволит избежать неприятных неожиданностей.
FilterChainIT.groovy


@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FilterChainIT {

  @Autowired
  FilterChainProxy filterChainProxy;

  @Autowired
  List<Filter> filters;

  @Test
  void 'test main filter chain'() {
    assertEquals(5, filters.size());

    assertEquals(OrderedCharacterEncodingFilter, filters[0].getClass())
    assertEquals(OrderedHiddenHttpMethodFilter, filters[1].getClass())
    assertEquals(OrderedHttpPutFormContentFilter, filters[2].getClass())
    assertEquals(OrderedRequestContextFilter, filters[3].getClass())

    assertEquals("springSecurityFilterChain", filters[4].filterName)
  }

  @Test
  void 'test security filter chain order'() {
    assertEquals(2, filterChainProxy.getFilterChains().size());

    def chain = filterChainProxy.getFilterChains().get(1);

    assertEquals(chain.filters.size(), 11)

    assertEquals(WebAsyncManagerIntegrationFilter, chain.filters[0].getClass())
    assertEquals(SecurityContextPersistenceFilter, chain.filters[1].getClass())
  }

  @Test
  void 'test ignored patterns'() {
    def chain = filterChainProxy.getFilterChains().get(0);

    assertEquals("/css/**", chain.requestMatcher.requestMatchers[0].pattern);
    assertEquals("/js/**", chain.requestMatcher.requestMatchers[1].pattern);
    assertEquals("/images/**", chain.requestMatcher.requestMatchers[2].pattern);
  }
}

Не вызывайте SecurityContextHolder.getContext().getAuthentication(); для получения текущего юзера


Authentication — сам по себе не очень удобный для использования объект. Почти все методы возвращают Object, а чтобы получить нужную информацию нужно кастить в конкретную реализацию.

Лучше заведите интерфейс, сделайте реализацию в зависимости от потребностей, напишите HandlerMethodArgumentResolver.

Код с таким подходом лучше читать, тестировать, поддерживать.


interface Auth {
  ...
}

public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

  @Override
  public boolean supportsParameter(MethodParameter parameter) {
    return parameter.getParameterType().equals(Auth.class);
  }

  @Override
  public Auth resolveArgument(MethodParameter parameter, 
                ModelAndViewContainer mavContainer,
                NativeWebRequest webRequest, 
                WebDataBinderFactory binderFactory) {

    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

    return toAuth(principal)
  }
}

@GetMapping
public String get(Auth auth) {
  return "hello " + auth.getId();
}

Расширяйте существующие реализации


Spring security содержит множество интрефейсов которые можно имплементировать, но скорее всего существует абрактный класс который на 99% делает то что вам нужно.

Например для интерфейса Authentication, существует AbstractAuthenticationToken, а аутенфикационный фильтр разумно отнаследовать от AbstractAuthenticationProcessingFilter

Используйте SecurityConfigurerAdapter чтобы сконфигурировать вашу аутентификацию


В том случае если у вас полностью кастомная аутентификация, скорее всего вам пришлось сделать следующее:

1. Создать реализацию Authentication
2. Создать AuthenticationProvider который поддерживает вашу реализацию Authentication
3. Добавить фильтр который начинал процесс аутентификации.

Разумно объеденить их всех в одном месте. Посмотрите на HttpBasicConfigurer, OpenIDLoginConfigurer они делают тоже самое.


class MyConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
  
  @Override
  public void configure(HttpSecurity http) throws Exception {

    AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
    MyAuthenticationProvider myAuthenticationProvider = http.getSharedObject(MyAuthenticationProvider.class);

    MyAuthenticationFilter filter = new MyAuthenticationFilter(authenticationManager);
    
    http
      .authenticationProvider(myAuthenticationProvider)
      .addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
  }
}

public class SecurityConfig extends WebSecurityConfigurerAdapter {

  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests().anyRequest().authenticated()
    .and()
      .apply(new MyConfigurer())
  }
}
  

Для ограничения вызова методов по ролям используйте @Secured\@PreAuthorize


Напишите тест который пройдет по всем методам конроллеров и проверит наличие @Secured\@PreAuthorize аннотаций.

При настройке WebSecurityConfigurerAdapter требуйте наличия авторизации для всех url. При необходимости добавляйте исключения. Исключения должны быть как можно более строгие.
Явно указывайте тип http метода, а url должен быть как можно более полным.

Лучше явно указать полный путь до метода, даже если на момент написания других api с таким endpoint-ом не было.

Например если есть контроллер с двумя GET методами: "url/methodOne", "url/methodTwo",
Не стоит делать так:


 authorizeRequests().antMatchers(HttpMethod.GET, "url/**").permitAll().

Лучше напишите:


 authorizeRequests().antMatchers(HttpMethod.GET, "url/methodOne", "url/methodTwo").permitAll().

В случае проблем включите org.springframework.security: debug


Spring Security имеет достаточно подробные debug логи, зачастую одних их достаточно чтобы понять суть проблемы.

Различайте antMatchers(«permit_all_url»).permitAll() и web.ignoring().antMatchers(«ignored_url»)



@Override
protected void configure(HttpSecurity http) throws Exception {
  http.authorizeRequests().
      anyRequest()
      .authenticated()
      .antMatchers("permit_all_url")
      .permitAll();
}

@Override
public void configure(WebSecurity web) throws Exception {
  web.ignoring().antMatchers("ignored_url");
}

В случае «ignored_url» будет проверяться на этапе выбора security filter chain и если url совпадет то будет использован пустой фильтр.

В случае «permit_all_url» проверка будет проходить на этапе AccessDecisionManager.




К сожалению, не доступен сервер mySQL