Команда 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
Система поддерживает следующие базовые сценарии:
Регистрация пользователя.
Аутентификация и Авторизация пользователя через форму логина, за которыми следует перенаправление на страницу пользователя.
Бизнес процесс — запрос количества зарегистрированных пользователей.
Обновление токена.
Общая конфигурация 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:
Чтобы разрешить доступ к эндпоинту /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:
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.
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.
Процесс обновления токена похож на процесс логина:
Клиент обращается к эндпоинту
/refreshToken
с запросом на обновление токена.Запрос перехватывается RefreshTokenAuthenticationFilter, потому что заданный URI эндпоинта включен в список разрешенных URI для фильтра.
Фильтр пытается осуществить аутентификацию, используя метод
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;
Мы используем библиотеку Axios для отправки запросов на сервер.
Мы регистрируем в Axios интерцептор запросов, который перехватывает все запросы и добавляет к ним токен, используя метод
authHeader()
.-
Мы регистрируем в Axios интерцептор ответов, который перехватывает все ответы и выполняет следующую логику:
-
Если ответ"неудачный", мы проверяем код статуса:
Если ответ содержит код статуса
401
(например, в случае невалидного или отсутствующего токена), мы удаляем всю информацию о существующих токенах и перенаправляем на страницу логина.-
Если ответ содержит код истечения токена (этот код генерируется сервером во время валидации токена в
TokenAuthenticationProvider
иRefreshTokenAuthenticationProvider
), Мы дополнительно проверяем, было ли исходное обращение запросом на обновление токена:Если первоначальный запрос был обычным бизнес запросом, сообщение об истечении токена указывает на то, что токен доступа истек. Чтобы обновить токен доступа, мы посылаем запрос на обновление токена с
refreshToken
. Затем мы сохраняем новую пару токенов из ответа и повторяем первоначальный бизнес-запрос с обновленным токеном.Если первоначальный запрос являлся запросом на обновление токена, сообщение об истечении токена означает, что
refreshToken
тоже истек. В этом случае пользователю придется логиниться заново. Поэтому мы удаляем всю информацию о существующих токенах и перенаправляем на страницу входа.
Если ответ успешный, мы отправляем его клиенту.
-
Заключение
В этом примере мы подробно и с использованием диаграмм потоков рассмотрели несколько ключевых процессов работы со 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 и всего, что с ним связано.