Spring Security: разберём по полочкам токены, фильтры и авторизацию (с диаграммами!)

от автора

Команда Spring АйО перевела статью, в которой подробно рассматривается процесс регистрации и аутентификации пользователей с использованием Spring Security.


Ключевые моменты

  • Spring Security является фреймворком построенным на Java/Jakarta EE, который предоставляет аутентификацию, авторизацию и другие функции безопасности для Enterprise-приложений.

  • Разработчики могут реализовывать полноценные конфигурации в рамках интерфейса SecurityFilterChain для управления защитой CORS и CSRF и фильтрами аутентификации, при этом оставляя доступными отдельные эндпоинты, например для создания учетной записи и входа в систему.

  • Токены доступа и обновления могут использоваться для достижения баланса между мерами безопасности и удобством пользователя, минимизируя риск взлома токена при расширении возможностей для пользователя.

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

  • Для лучшего понимания последовательности вызовов API, которые Spring Security выполняет «под капотом», можно использовать диаграммы потоков.

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

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

Вы можете найти исходный код этих примеров в этом GitHub репозитории.

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

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

  • Аутентификация (Authentication) — это процесс проверки личности пользователя. Мы понимаем кто перед нами.

  • Авторизация (Authorization) — это процесс определения, какие ресурсы или действия должны быть доступны пользователю. 

  • Токен доступа (Access Token) — это набор данных, содержащий информацию, необходимую для идентификации пользователя или предоставления доступа к ограниченным ресурсам. 

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

    • Разрешение приложению клиента получить новую пару токенов после истечения токена доступа, не требуя нового логина от пользователя.

    • Сокращение периода, в течение которого токен доступа подвержен риску компрометации.

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

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

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

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

  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(); }

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

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

Когда пользователь заполняет регистрационную форму со всеми обязательными полями и отправляет запрос, выполняются все действия как показано на Рисунке 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);         }     }

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

Как только запрос успешно обработан фильтром токенов, он поступает на обработку в бизнес контроллер, как показано на Рисунке 2:

Рисунок 2: Аутентификация и авторизация пользователя через форму логина

Рисунок 2: Аутентификация и авторизация пользователя через форму логина

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 мы при создании фильтра также задаем обработчики для успешных и неуспешных авторизаций, а также AuthenticationManager. Ниже мы поговорим о них более подробно.

  • Добавьте этот 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. Когда мы создаем объект типа 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, который мы определили при создании фильтра (как было упомянуто ранее). Этот обработчик содержит один переопределенный метод onAuthenticationSuccess(), где мы обычно записываем сгенерированные токены и устанавливаем успешный код ответа для запроса.

@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. После успешной верификации фильтр перенаправляет запрос в стандартную цепочку фильтров веб приложения, в результате чего запрос попадает в бизнес контроллер 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.

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

Процесс обновления токена показан на Рисунке 4.

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

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

Процесс обновления токена похож на процесс логина:

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

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

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

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

4. После успешной аутентификации метод successAuthentication() вызывает тот же обработчик, 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. Мы регистрируем в Axios интерцептор ответов, который перехватывает все ответы и выполняет следующую логику:

    1. Если ответ»неудачный», мы проверяем код статуса:

      1. Если ответ содержит код статуса 401 (например, в случае невалидного или отсутствующего токена), мы удаляем всю информацию о существующих токенах и перенаправляем на страницу логина.

      2. Если ответ содержит код истечения токена (этот код генерируется сервером во время валидации токена в TokenAuthenticationProvider и RefreshTokenAuthenticationProvider), Мы дополнительно проверяем, было ли исходное обращение запросом на обновление токена:

        1. Если первоначальный запрос был обычным бизнес запросом, сообщение об истечении токена указывает на то, что токен доступа истек. Чтобы обновить токен доступа, мы посылаем запрос на обновление токена с refreshToken. Затем мы сохраняем новую пару токенов из ответа и повторяем первоначальный бизнес-запрос с обновленным токеном.

        2. Если первоначальный запрос являлся запросом на обновление токена, сообщение об истечении токена означает, что refreshToken тоже истек. В этом случае пользователю придется логиниться заново. Поэтому мы удаляем всю информацию о существующих токенах и перенаправляем на страницу входа.

    2. Если ответ успешный, мы отправляем его клиенту. 

Заключение

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


Несмотря на то, что Josh Long считает эту статью «обязательной к прочтению»:

A very interesting article on the flow diagrams for Spring Security—a must-bookmark!

У экспертов сообщества к ней возникли некоторые вопросы: 

  • Зачем для регистрации и логина использовать дополнительный матчер и исключения путей в фильтре, если можно просто применять permitAll()?

  • Почему после успешной регистрации сразу не выдаётся токен для аутентификации

  • Что с токенами, а именно с их хранением, шифрованием, временем жизни и почему JWT оторван от OAuth 2.0?

Будем рады обсуждению в комментариях!

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


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


Комментарии

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

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