Как стать автором
Поиск
Написать публикацию
Обновить
120.03

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

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров2.1K
Автор оригинала: Alexandr Manunin

В новом переводе от команды 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 и всего, что с ним связано

Теги:
Хабы:
+6
Комментарии0

Публикации

Информация

Сайт
t.me
Дата регистрации
Численность
11–30 человек