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