Эта статья является частью книги про Spring Framework, которая по планам должна выйти где-нибудь в 2024 году, ну в крайнем случае в 2025, если не все будет получаться.
Spring Security уже содержит стандартные фильтры, поддерживающие HTTP Basic аутентификацию, аутентификацию с паролем и логином с формы, аутентификацию на основе сертификатов и многие другие. Однако мы можем также написать свой собственный фильтр, благодаря которому мы сможем отправлять логин и пароль внутри JSON в POST-запросе к нашему сервису.
Конкретно в этой статье показывается пример для простого Spring Boot приложения с двумя контроллерами. В моей будущей книге по Spring Framework будет также пример и для проекта без Spring Boot и с конфигурацией в XML-файлах.
Первый контроллер просто возвращает JSON, но должен быть доступен только пользователям с ролью ROLE_USER:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package ru.urvanov.javaexamples.springsecuritycustomfilter; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("rest/v1/main") public class MainController { @RequestMapping(method = RequestMethod.POST) public MainResult main(@RequestBody MainArg arg) { MainResult result = new MainResult(); result.setMessage("The word is " + arg.getWord()); return result; } } |
Второй контроллер возвращает JSON об успешной аутентификации. Он должен быть доступен всем без исключения:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package ru.urvanov.javaexamples.springsecuritycustomfilter; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("rest/v1/login") public class LoginController { @RequestMapping(method = RequestMethod.POST) public LoginResult login(@RequestAttribute LoginArg loginArg) { LoginResult result = new LoginResult(); result.setSuccess(true); return result; } } |
Для аутентификации пользователей используется JSON вида:
1 2 3 4 |
{ "username" : "user", "password" : "password" } |
Его мы будем отображать на класс LoginArg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package ru.urvanov.javaexamples.springsecuritycustomfilter; public class LoginArg { private String username; private String password; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } |
Нам нужно в слое безопасности приложения разобрать этот JSON и аутентифицировать пользователя.
Всю конфигурацию будем располагать в классе SecurityConfig:
1 2 3 4 |
@Configuration public class SecurityConfig { ... } |
Сначала нам нужно отключить конфигурацию Spring Security по умолчанию от Spring Boot. Для этого достаточно просто объявить бин securityFilterChain:
1 2 3 4 |
@Bean public SecurityFilterChain securityFilterChain() { ... } |
Внутри этого метода мы должны сконфигурировать SecurityFilterChain на основе HttpSecurity, используя его методы authorizeHttpRequests:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@Bean public SecurityFilterChain securityFilterChain( HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { http .authenticationManager(authenticationManager) .csrf((csrf) -> csrf.disable()) .authorizeHttpRequests( (c) -> c.requestMatchers("/rest/v1/login") .permitAll()) .authorizeHttpRequests( (c) -> c.requestMatchers("/rest/**") .hasRole("USER")); return http.build(); } |
Здесь мы просто отключили CSRF (для production лучше включить и настроить, скорее всего), в тестовом примере он нам будет только мешать.
С помощью методов authorizeHttpRequests мы настраиваем различные доступы к URL. Например, к /rest/v1/login разрешены все запросы ( permitAll), а к всем остальным URL внутри /rest будут иметь только пользователи с ролью ROLE_USER ( hasRole("USER")).
Мы используем свой authenticationManager, чтобы настроить успешную аутентификацию для одного пользователя с жестко определённым логином и паролем (для тестового примера этого будет достаточно). Выглядит это так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@Bean public UserDetailsService userDetailsService() { UserDetails user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build(); return new InMemoryUserDetailsManager(user); } @Bean public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) { DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(userDetailsService); return authenticationProvider; } @Bean public AuthenticationManager authenticationManager( HttpSecurity http, AuthenticationProvider authenticationProvider) throws Exception { AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); authenticationManagerBuilder.authenticationProvider( authenticationProvider); return authenticationManagerBuilder.build(); } |
Мы просто объявили authenticationManager с одним authenticationProvider, который принимает решение об успешной аутентификации только для пользователя с логином “user” и паролем “password”.
После успешной аутентификации пользователь должен оставаться залогиненным в рамках текущей сессии. Для этого мы должны объявить бин HttpSessionSecurityContextRepository.
1 2 3 4 |
@Bean public HttpSessionSecurityContextRepository securityContextRepository() { return new HttpSessionSecurityContextRepository(); } |
HttpSessionSecurityContextRepository сохраняет SecurityContext в сессии, что оставляет пользователя аутентифицированным в рамках одной HTTP-сессии, которая будет идентифицироваться по куке JSESSIONID, приходящей в HTTP-заголовке Cookie запроса.
Осталась основная логика по разбору входящего JSON внутри своего фильтра:
1 2 3 4 5 6 7 8 9 10 |
@Bean public CustomAuthenticationProcessingFilter customAuthenticationProcessingFilter( AuthenticationManager authenticationManager, SecurityContextRepository securityContextRepository) { CustomAuthenticationProcessingFilter filter = new CustomAuthenticationProcessingFilter( authenticationManager); filter.setSecurityContextRepository(securityContextRepository); return filter; } |
Сам фильтр CustomAuthenticationProcessingFilter выглядит так (обратите внимание на метод attemptAuthentication, где и происходит сам разбор входящего JSON):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
package ru.urvanov.javaexamples.springsecuritycustomfilter; import java.io.IOException; import java.util.Collections; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; public class CustomAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { protected CustomAuthenticationProcessingFilter( AuthenticationManager authenticationManager) { super(new AntPathRequestMatcher("/rest/v1/login", "POST"), authenticationManager); this.setAuthenticationSuccessHandler( new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException { } @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { } }); this.setSessionAuthenticationStrategy( new ChangeSessionIdAuthenticationStrategy()); } @Override public Authentication attemptAuthentication( HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { LoginArg creds = new ObjectMapper() .readValue(request.getInputStream(), LoginArg.class); request.setAttribute("loginArg", creds); return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( creds.getUsername(), creds.getPassword(), Collections.emptyList() )); } protected void successfulAuthentication( HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication( request, response, chain, authResult); chain.doFilter(request, response); } } |
Немного разберём, что мы здесь понаписали, чтобы не оставлять вас просто с куском кода.
Метод attemptAuthentication, как уже говорилось выше разбирает входной запрос и сохраняет разобранную версию в requestAttribute. Нам обязательно нужно сохранить разобранную версию, так как иначе мы потом не сможем достать LoginArg внутри LoginController, а входной ServletInputStream уже будет считанный до конца, считать его ещё раз мы не сможем.
Мы также объявляем свой AuthenticationSuccessHandler, который не делает вообще ничего. Если этого не делать, то будет использоваться SavedRequestAwareAuthenticationSuccessHandler из AbstractAuthenticationProcessingFilter, от которого мы отнаследовались. Он будет редиректить нас на другую страницу, а нам этого не нужно, нам нужно пройтись дальше по всей цепочке фильтров и обработчиков вплоть до LoginController.
Внутри метода successfulAuthentication мы запускаем дальнейшую обработку запроса в соответствии с цепочкой фильтров:
1 |
chain.doFilter(request, response); |
Исходные коды можно скачать по ссылкам ниже. Внутри полностью рабочий проект Maven, а также коллекция запросов Postman с работающими методами login и main к нашим контроллерам.
А это нормально прям пароль отправлять?
Если по HTTPS, то нормально. Когда на сайтах форма логина и пароля, то он прямо текстом обычно и отправляется, только не в JSON, а как поле в теле POST-запроса.
Обычно примерно так выглядит: