Конфигурация Spring Security на пальцах

от автора

В новом переводе от команды Spring АйО мы рассмотрим решение для регистрации и аутентификации пользователя через клиентское JavaScript-приложение с использованием инфраструктуры Spring Security, а также access и refresh токенов.

Существует множество базовых примеров работы со Spring Security, поэтому цель данной статьи — более подробно описать возможный процесс с помощью блок-схем.

Комментарий от команды Spring АйО

Данный пример – лишь один из сценариев работы с JWT.

Код приложения, который включает в себя код примера и массу иных настроек секьюрити доступен в этом репозитории на GitHub.

Примечание: в этой статье рассматриваются только базовые успешные сценарии. Обработка ошибок и исключений опущена.

Терминология

Аутентификация — процесс проверки подлинности пользователя
Авторизация — процесс определения, какие ресурсы или действия доступны пользователю.
Токен доступа (Access Token) — объект данных, содержащий информацию, необходимую для идентификации пользователя или предоставления доступа к защищённым ресурсам.
Refresh Token — учетные данные, позволяющие клиентскому приложению получать новые access-токены без необходимости повторного входа пользователя в систему. Концепция refresh-токена представляет собой компромисс между безопасностью и удобством использования. Длительное хранение access-токена увеличивает риск его компрометации, тогда как частые запросы на повторную авторизацию ухудшают пользовательский опыт.
Refresh-токены решают эту проблему за счёт:
— предоставления клиентскому приложению возможности получить новую пару токенов после истечения срока действия access-токена без повторного входа пользователя;
— сокращения временного окна, в течение которого access-токен может быть скомпрометирован.

Список базовых процессов и конфигурация Spring Security

Система поддерживает следующие базовые сценарии:

  1. Регистрация пользователя.

  2. Аутентификация и авторизация пользователя через форму входа (login form) с последующим перенаправлением на пользовательскую страницу.

  3. Бизнес-процесс — запрос количества зарегистрированных пользователей.

  4. Обновление токена.

Общая конфигурация Spring Security реализуется в методе filterChain(), определённом в классе SecurityConfiguration:

@Bean SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {     http        .cors(cors ->  cors.configurationSource(corsConfigurationSource()))        .csrf(AbstractHttpConfigurer::disable)        .exceptionHandling(configurer -> configurer            .accessDeniedHandler(accessDeniedHandler))        .sessionManagement(configurer -> configurer            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))        .authorizeHttpRequests(authorize -> authorize            .requestMatchers(SIGNIN_ENTRY_POINT).permitAll()            .requestMatchers(SIGNUP_ENTRY_POINT).permitAll()            .requestMatchers(SWAGGER_ENTRY_POINT).permitAll()            .requestMatchers(API_DOCS_ENTRY_POINT).permitAll()            .requestMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll()            .anyRequest().authenticated()        )        .addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)        .addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)        .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class); http.oauth2Login(configurer -> configurer        .authorizationEndpoint(config -> config                .authorizationRequestRepository(authorizationRequestRepository()))        .failureHandler(failureHandler)        .successHandler(oauth2AuthenticationSuccessHandler));  return http.build(); }
Комментарий от команды Spring АйО

Метод создающий бин типа SecurityFilterChain и принимающий в качестве аргумента обьект HttpSecurity помогает настроить безопасность для определенных HTTP-запросов, определяя для них отдельную цепочку фильтров.

Класс WebSecurity помогает настроить безопасность на глобальном уровне в приложении. Мы можем настроить WebSecurity, предоставив компонент WebSecurityCustomizer.

В отличие от класса HttpSecurity, который помогает настраивать правила безопасности для определённых шаблонов URL или отдельных ресурсов, конфигурация WebSecurity применяется глобально ко всем запросам и ресурсам.

Рассмотрим каждый сценарий по отдельности.

Регистрация пользователя

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

Рисунок 1 – Регистрация пользователя

Рисунок 1 – Регистрация пользователя

Чтобы разрешить доступ к эндпоинту /signup и позволить запросам обходить требование аутентификации по умолчанию в Spring Security, необходимо сконфигурировать Spring Security таким образом, чтобы доступ к этому конкретному эндпоинту был разрешён без предварительной аутентификации.

Это можно реализовать, изменив конфигурацию безопасности и исключив эндпоинт /signup из списка, требующего аутентификации.

Вот как можно настроить Spring Security для разрешения доступа к /signup с помощью следующего фрагмента метода filterChain(), определённого в классе SecurityConfiguration:

.authorizeHttpRequests(authorize -> authorize    .requestMatchers(SIGNIN_ENTRY_POINT).permitAll()    .requestMatchers(SIGNUP_ENTRY_POINT).permitAll()    .requestMatchers(SWAGGER_ENTRY_POINT).permitAll()    .requestMatchers(API_DOCS_ENTRY_POINT).permitAll()    .requestMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll()    .anyRequest().authenticated() )

Следующий важный момент заключается в том, что конфигурация включает токен-фильтр, который перехватывает все входящие запросы и проверяет наличие токена в них. Это реализуется с помощью следующего фрагмента метода filterChain():

.addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);

Чтобы исключить проверку токена для запроса регистрации, необходимо задать механизм распознавания путей, с которыми будет работать этот фильтр, при его создании. Рассмотрим метод buildTokenAuthenticationFilter(), определённый в классе SecurityConfiguration:

protected TokenAuthenticationFilter buildTokenAuthenticationFilter() {     List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));     SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip);     TokenAuthenticationFilter filter = new TokenAuthenticationFilter(jwtTokenProvider, matcher, failureHandler);     filter.setAuthenticationManager(this.authenticationManager);     return filter; }

Здесь используется класс SkipPathRequestMatcher (показан ниже), который исключает указанные в параметре pathsToSkip пути из области действия фильтра (в нашем случае в этот массив был добавлен SIGNUP_ENTRY_POINT).

public class SkipPathRequestMatcher implements RequestMatcher {     private final OrRequestMatcher matchers;      public SkipPathRequestMatcher(final List<String> pathsToSkip) {         Assert.notNull(pathsToSkip, "List of paths to skip is required.");         List<RequestMatcher> m = pathsToSkip.stream()             .map(AntPathRequestMatcher::new)             .collect(Collectors.toList());         matchers = new OrRequestMatcher(m);         }      @Override     public boolean matches(final HttpServletRequest request) {         return !matchers.matches(request);         }     }

Аутентификация и авторизация пользователя через Login Form

После того как запрос успешно проходит токен-фильтр, он передаётся на обработку бизнес-контроллеру, как показано на рисунке 2:

Рисунок 2: Аутентификация и авторизация пользователя через форму входа (Login Form)

Рисунок 2: Аутентификация и авторизация пользователя через форму входа (Login Form)
  1. Клиент отправляет имя пользователя и пароль на серверный эндпоинт /login.

  2. Чтобы фильтр LoginAuthenticationFilter перехватывал этот запрос, необходимо соответствующим образом настроить Spring Security:

  • определить этот фильтр и указать URI, по которому он будет обрабатывать запросы, с помощью метода buildLoginProcessingFilter(), определённого в классе SecurityConfiguration:

@Bean protected LoginAuthenticationFilter buildLoginProcessingFilter() {     LoginAuthenticationFilter filter = new LoginAuthenticationFilter(SIGNIN_ENTRY_POINT, authenticationSuccessHandler, failureHandler);     filter.setAuthenticationManager(this.authenticationManager);     return filter; }

Обратите внимание, что помимо указания URI, при создании фильтра мы также задаём обработчики успешной и неуспешной авторизации, а также менеджер аутентификации. Подробнее об этих компонентах будет рассказано ниже.

  • добавьте этот URI в список исключений для токен-фильтра с помощью метода buildTokenAuthenticationFilter(), определённого в классе SecurityConfiguration:

List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));
  • добавьте созданный фильтр в конфигурацию через метод filterChain():

@Bean SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {      http              .cors(cors -> cors.configurationSource(corsConfigurationSource()))  // our builder configuration           .addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);   // our builder configuration   return http.build(); }

В классе LoginAuthenticationFilter мы переопределяем два метода, которые Spring вызывает при выполнении фильтра. Первый из них — attemptAuthentication(), в котором мы инициируем запрос на аутентификацию, передавая его методу AuthenticationManager, указанному при создании фильтра. Однако сам менеджер не выполняет аутентификацию — он служит контейнером для провайдеров, которые занимаются этой задачей. Интерфейс AuthenticationManager отвечает за поиск подходящего провайдера и передачу ему запроса.

Вот как создаётся менеджер и регистрируются провайдеры:

@Bean public AuthenticationManager authenticationManager(final ObjectPostProcessor<Object> objectPostProcessor) throws Exception {         var auth = new AuthenticationManagerBuilder(objectPostProcessor);         auth.authenticationProvider(loginAuthenticationProvider);         auth.authenticationProvider(tokenAuthenticationProvider);         auth.authenticationProvider(refreshTokenAuthenticationProvider);         return auth.build(); }

Далее этот менеджер передаётся в качестве параметра каждому создаваемому фильтру.

3. Чтобы AuthenticationManager смог найти нужного провайдера (в нашем случае — LoginAuthenticationProvider), необходимо указать в самом провайдере, какой тип он поддерживает. Это реализуется в методе supports(), как показано ниже:

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

В нашем примере мы указываем, что провайдер поддерживает класс UsernamePasswordAuthenticationToken. Когда в фильтре создаётся объект этого типа и передаётся в AuthenticationManager, тот может корректно определить нужный провайдер на основе типа объекта. Это происходит в методе attemptAuthentication(), определённом в классе LoginAuthenticationFilter:

@Override public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException {     // some code above     UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());     token.setDetails(authenticationDetailsSource.buildDetails(request));     return this.getAuthenticationManager().authenticate(token); }

4. После того как AuthenticationManager находит нужного провайдера, он вызывает метод authenticate(), и провайдер непосредственно выполняет проверку логина и пароля пользователя. Затем результат возвращается обратно в фильтр.

5. Второй метод, который мы переопределяем в фильтре, — successfulAuthentication(). Spring вызывает его при успешной аутентификации. Обработку успешной аутентификации выполняет интерфейс AuthenticationSuccessHandler из Spring Security, который мы указали при создании фильтра (как упоминалось ранее). В этом обработчике переопределяется метод onAuthenticationSuccess(), в котором, как правило, записываются сгенерированные токены и устанавливается код успешного ответа на запрос.

// LoginAuthenticationSuccessHandler  @Override public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException {     UserDetails userDetails = (UserDetails) authentication.getPrincipal();      JwtPair jwtPair = tokenProvider.generateTokenPair(userDetails);      response.setStatus(HttpStatus.OK.value());     response.setContentType(MediaType.APPLICATION_JSON_VALUE);     JsonUtils.writeValue(response.getWriter(), jwtPair); }

Затем инфраструктура Spring, получив успешный ответ, перенаправляет его клиенту.

Бизнес-процесс — запрос количества зарегистрированных пользователей

В нашем примере в качестве бизнес-запроса рассматривается получение количества пользователей в базе данных. Предполагается, что для любого запроса, инициированного авторизованным пользователем, необходимо проверить токен. Процесс проверки токена запускается фильтром TokenAuthenticationFilter, а затем, по аналогии с ранее описанным процессом, запрос передаётся провайдеру TokenAuthenticationProvider.

После успешной проверки фильтр перенаправляет запрос в стандартный filter chain веб-приложения, в результате чего он достигает бизнес-контроллера AuthController, как показано на рисунке 3.

Рисунок 3: Запрос количества зарегистрированных пользователей

Рисунок 3: Запрос количества зарегистрированных пользователей
  1. Клиент отправляет запрос на серверный эндпоинт /users/count вместе с токеном.

  2. Чтобы фильтр TokenAuthenticationFilter смог перехватить этот запрос, необходимо настроить его в конфигурации Spring Security:

  • создайте этот фильтр (мы уже рассматривали его в предыдущих процессах) и укажите URI, по которым он будет фильтровать запросы — в данном случае, это все запросы, за исключением тех, что исключены в классе SkipPathRequestMatcher. Для этого фильтр необходимо настроить в конфигурации Spring Security с помощью метода buildTokenAuthenticationFilter(), как показано ниже:

protected TokenAuthenticationFilter buildTokenAuthenticationFilter() {     List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));     SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip);     TokenAuthenticationFilter filter = new TokenAuthenticationFilter(jwtTokenProvider, matcher, failureHandler);     filter.setAuthenticationManager(this.authenticationManager);     return filter; }

Аналогично предыдущему фильтру, мы указываем AuthenticationManager, который будет вызван для поиска соответствующего провайдера.

  • добавьте созданный фильтр в конфигурацию с помощью нашего метода filterChain():

@Bean SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {     http         .cors(cors -> cors.configurationSource(corsConfigurationSource()))  // our builder configuration          .addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);   // our builder configuration     return http.build(); }

Чтобы AuthenticationManager смог найти нужного провайдера, используется метод authenticationManager():

@Bean public AuthenticationManager authenticationManager(final ObjectPostProcessor<Object> objectPostProcessor) throws Exception {     var auth = new AuthenticationManagerBuilder(objectPostProcessor);     auth.authenticationProvider(loginAuthenticationProvider);     auth.authenticationProvider(tokenAuthenticationProvider);     auth.authenticationProvider(refreshTokenAuthenticationProvider);     return auth.build(); }
  • В самом провайдере необходимо указать тип, по которому будут фильтроваться запросы. Это делается через метод supports(), определённый в классе TokenAuthenticationProvider:

@Override public boolean supports(final Class<?> authentication) {    return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); }

В результате фильтр должен сформировать объект JwtAuthenticationToken. Далее AuthenticationManager определит подходящего провайдера на основе типа этого объекта и передаст его на аутентификацию через метод attemptAuthentication(), определённый в классе TokenAuthenticationFilter.

@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {     return getAuthenticationManager().authenticate(new JwtAuthenticationToken(tokenProvider.getTokenFromRequest(request))); }

3. После успешной аутентификации метод successfulAuthentication() перенаправляет исходный запрос в цепочку стандартных фильтров, в результате чего он в конечном итоге достигает бизнес-контроллера AuthController.

Комментарий от команды Spring АйО

И выполняется бизнес-логика позволяющая получить всех зарегистрированных пользователей, которая уже не связана со Spring Security

Token Refresh

 Процесс работы Token Refresh представлен на рисунке 4.

Рисунок 4 – Token refresh

Рисунок 4 – Token refresh

Процесс обновления токена аналогичен процессу входа в систему:

  1. Клиент отправляет запрос на обновление токена на эндпоинт /refreshToken.

  2. Запрос перехватывается фильтром RefreshTokenAuthenticationFilter, поскольку указанный URI включён в список разрешённых для этого фильтра.

  3. Фильтр выполняет попытку аутентификации через метод attemptAuthentication(), обращаясь к AuthenticationManager, который, в свою очередь, вызывает RefreshTokenAuthenticationProvider. Как и в двух предыдущих примерах, этот провайдер выбирается потому, что поддерживает определённый тип — объект, который мы формируем в фильтре, — RefreshJwtAuthenticationToken:

@Override public boolean supports(final Class<?> authentication) {     return (RefreshJwtAuthenticationToken.class.isAssignableFrom(authentication)); }

4. После успешной аутентификации метод successfulAuthentication() вызывает тот же обработчик — LoginAuthenticationSuccessHandler, что и в процессе входа в систему. Этот обработчик записывает сгенерированную пару токенов в ответ.

Описание процесса на стороне клиента

Изобразить процесс на стороне JavaScript-приложения с помощью блок-схемы представляется довольно громоздким из-за разветвлений, зависящих от ответа сервера. Поэтому сосредоточимся непосредственно на коде, который достаточно лаконичен, и пошагово разберём, что в нём происходит. Рассмотрим файл apiClient.js:

// import statements  const userStore = useUserStore();  // axios client init const apiClient = axios.create({   baseURL: process.env.API_URL });  // add token from userStore function authHeader() {   let token = userStore.getToken;   if (token) {     return {Authorization: 'Bearer ' + token};   } else {     return {};   } }  // add an interceptor that includes a token to each request apiClient.interceptors.request.use(function (config) {   config.headers = authHeader();   return config; });  //add an interceptor that processes each response apiClient.interceptors.response.use(function (response) {   return response;  //successful response }, function (error) { //unsuccessful response   const req = error.config;   if (isTokenExpired(error)) {      if (isRefreshTokenRequest(req)) {       //refreshToken is expired, clean token info and redirect to login page       clearAuthCache();       window.location.href = '/login?expired=true';     }     // token is expired, token refresh is required     return authService.refreshToken(userStore.getRefreshToken).then(response => {       //save new token pair to store       userStore.login(response);       //repeat original business request       return apiClient.request(req);     });   }   //the code 401 we set on backend side in any unsuccessful authentication    // including incorrect or empty tokens    if (error.response?.status === 401) {     clearAuthCache();   }   return Promise.reject(error); });  export default apiClient;
  1. Мы используем библиотеку Axios для отправки запросов на сервер. 

  2. В Axios мы регистрируем перехватчик запросов, который перехватывает все исходящие запросы и добавляет к ним токен (с помощью метода authHeader()).

  3. Также мы регистрируем перехватчик ответов, который перехватывает все входящие ответы и выполняет следующую логику:

— Если ответ неуспешный, проверяется статус-код:
    — Если ответ содержит статус 401 (например, в случае недействительного или отсутствующего токена), мы удаляем всю информацию о текущих токенах и выполняем перенаправление на страницу входа.
    — Если ответ содержит код, указывающий на истечение срока действия токена (этот код формируется сервером во время проверки токена в TokenAuthenticationProvider и RefreshTokenAuthenticationProvider), дополнительно проверяется, был ли исходный запрос запросом на обновление токена:
        — Если исходный запрос был обычным бизнес-запросом, сообщение об истечении срока действия токена означает, что истёк accessToken. Для его обновления отправляется запрос на обновление токена с использованием refreshToken. Затем новая пара токенов сохраняется, и оригинальный бизнес-запрос повторяется с обновлённым токеном.
        — Если исходный запрос был запросом на обновление токена, это означает, что истёк также и refreshToken. В этом случае пользователю необходимо снова пройти авторизацию. Мы удаляем всю информацию о текущих токенах и перенаправляем на страницу входа.

— Если ответ успешный, он передаётся клиенту.

Заключение

В этом примере мы подробно рассмотрели несколько ключевых процессов работы со Spring Security и токенами, используя блок-схемы.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано


ссылка на оригинал статьи https://habr.com/ru/articles/935470/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *