В новом переводе от команды Spring АйО мы рассмотрим решение для регистрации и аутентификации пользователя через клиентское JavaScript-приложение с использованием инфраструктуры Spring Security, а также access и refresh токенов.
Существует множество базовых примеров работы со Spring Security, поэтому цель данной статьи — более подробно описать возможный процесс с помощью блок-схем.
Комментарий от команды Spring АйО
Данный пример – лишь один из сценариев работы с JWT.
Код приложения, который включает в себя код примера и массу иных настроек секьюрити доступен в этом репозитории на GitHub.
Примечание: в этой статье рассматриваются только базовые успешные сценарии. Обработка ошибок и исключений опущена.
Терминология
Аутентификация — процесс проверки подлинности пользователя
Авторизация — процесс определения, какие ресурсы или действия доступны пользователю.
Токен доступа (Access Token) — объект данных, содержащий информацию, необходимую для идентификации пользователя или предоставления доступа к защищённым ресурсам.
Refresh Token — учетные данные, позволяющие клиентскому приложению получать новые access-токены без необходимости повторного входа пользователя в систему. Концепция refresh-токена представляет собой компромисс между безопасностью и удобством использования. Длительное хранение access-токена увеличивает риск его компрометации, тогда как частые запросы на повторную авторизацию ухудшают пользовательский опыт.
Refresh-токены решают эту проблему за счёт:
— предоставления клиентскому приложению возможности получить новую пару токенов после истечения срока действия access-токена без повторного входа пользователя;
— сокращения временного окна, в течение которого access-токен может быть скомпрометирован.
Список базовых процессов и конфигурация Spring Security
Система поддерживает следующие базовые сценарии:
-
Регистрация пользователя.
-
Аутентификация и авторизация пользователя через форму входа (login form) с последующим перенаправлением на пользовательскую страницу.
-
Бизнес-процесс — запрос количества зарегистрированных пользователей.
-
Обновление токена.
Общая конфигурация 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:
Чтобы разрешить доступ к эндпоинту /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:
-
Клиент отправляет имя пользователя и пароль на серверный эндпоинт
/login. -
Чтобы фильтр
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.
-
Клиент отправляет запрос на серверный эндпоинт
/users/countвместе с токеном. -
Чтобы фильтр
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.
Процесс обновления токена аналогичен процессу входа в систему:
-
Клиент отправляет запрос на обновление токена на эндпоинт
/refreshToken. -
Запрос перехватывается фильтром
RefreshTokenAuthenticationFilter, поскольку указанный URI включён в список разрешённых для этого фильтра. -
Фильтр выполняет попытку аутентификации через метод
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;
-
Мы используем библиотеку Axios для отправки запросов на сервер.
-
В Axios мы регистрируем перехватчик запросов, который перехватывает все исходящие запросы и добавляет к ним токен (с помощью метода
authHeader()). -
Также мы регистрируем перехватчик ответов, который перехватывает все входящие ответы и выполняет следующую логику:
— Если ответ неуспешный, проверяется статус-код:
— Если ответ содержит статус 401 (например, в случае недействительного или отсутствующего токена), мы удаляем всю информацию о текущих токенах и выполняем перенаправление на страницу входа.
— Если ответ содержит код, указывающий на истечение срока действия токена (этот код формируется сервером во время проверки токена в TokenAuthenticationProvider и RefreshTokenAuthenticationProvider), дополнительно проверяется, был ли исходный запрос запросом на обновление токена:
— Если исходный запрос был обычным бизнес-запросом, сообщение об истечении срока действия токена означает, что истёк accessToken. Для его обновления отправляется запрос на обновление токена с использованием refreshToken. Затем новая пара токенов сохраняется, и оригинальный бизнес-запрос повторяется с обновлённым токеном.
— Если исходный запрос был запросом на обновление токена, это означает, что истёк также и refreshToken. В этом случае пользователю необходимо снова пройти авторизацию. Мы удаляем всю информацию о текущих токенах и перенаправляем на страницу входа.
— Если ответ успешный, он передаётся клиенту.
Заключение
В этом примере мы подробно рассмотрели несколько ключевых процессов работы со Spring Security и токенами, используя блок-схемы.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано
ссылка на оригинал статьи https://habr.com/ru/articles/935470/
Добавить комментарий