Краткая предыстория
Один из моих проектов представлялет собой, по сути, набор REST-сервисов на Spring WebMVC с AngularJS-фронтендом. В качестве механизма аутентификации в REST-бекенде я выбрал token-аутентификацию. При этом клиент сначала авторизуется при помощи BASIC или Digest-авторизации, получает в ответ токен аутентификации и уже с ним обращается к REST-сервисам. Такая идея пришла в голову после прочтения https://spring.io/blog/2015/01/12/spring-and-angular-js-a-secure-single-page-application и нескольких постов на хабре. Код аутентификации по сути был эдакой сборной солянкой из обрывков кода из найденных постов. Красотой особой он не отличался, но работал как надо и на первое время сгодился. Большего от него и не требовалось.Однако это было, когда бекенд был обычным веб-приложением и жил себе спокойно в томкате. Но всё изменилось, когда я решил его перевести на Spring Boot. Процесс аутентификации просто напросто зацикливался и я в ответ получал StackOverflowException. Варианта решения этой проблемы для меня было три: отказаться от Spring Boot, вылечить моего Франкенштейна или же наконец-то разобраться с Spring Security и реализовать нормальный механизм аутентификации. В итоге я выбрал самый сложный и самый интересный вариант - последний.
Компоненты Spring Security
Процесс аутентификации в Spring Security проходит через следующие компоненты:
- Фильтр
Задача фильра - определить, удовлетворяет ли запрос условиям для запуска процесса аутентификации, и, если запрос будет им удовлетворять, создать объект аутентификации, попробовать его аутентифицировать при помощи менеджера аутентификации и, в случае успеха, положить объект аутентификации в контекст. Если же не получилось аутентифицировать пользователя по полученным данным, то можно либо продолжить цепочку фильтров в надежде аутентифцировать пользователя каким-то другим способом, либо вызвать метод commence точки входа, который сообщит пользователю, что данный метод аутентификации обязателен.
- Объект аутентификации (Authentication)
В объекте аутентификации хранится информация, полученная фильтром из запроса. По этому объекту менеджеры и провайдеры будут пытаться аутентифицировать пользователя в системе.
- Менеджер аутентификации (AuthenticationManager)
По сути, задача менеджера - обойти все известные ему провайдеры аутентификации, найти подходящий и попробовать аутентифицировать полученный от фильтра объект аутентификации. Менеджер аутентификации можно использовать стандартный, в него нужно будет только добавить нашего провайдера аутентификации.
- Провайдер аутентификации (AuthenticationProvider)
Провайдер проверяет, поддерживает ли он объект аутентификации указанного класса, и, если подерживает, пытается аутентифицировать пользователя. В стандартном подходе провайдер обращается к методу loadUserByUsername объекта класса UserDetailsService и, найдя его, проверяет предоставленные данные на соответствие полученным из БД (например, проверяет корректность введённого пароля). Если у провайдера получается аутентифицировать пользователя, то он возвращает объект авторизации с пометкой об успешной аутентификации и пользовательскими данными.
- Сервис пользовательских данных (UserDetailsService)
Этот сервис просто вытаскивает пользователя по полученным данным из хранилища (БД, памяти, properties-файла, нужное подчеркнуть).
- Пользовательские данные (UserDetails)
В пользовательских данных хранится необходимая информация об аутентифицированном пользователе - имя пользователя, пароль и список выданных ролей. Данный класс удобно расширять для хранения каких-то собственных данных о пользователе, которые понадобятся при обработке запроса.
Реализация аутентификации
В моём варианте аутентификация будет производиться по токену. Токен может передаваться в параметрах запроса, либо в заголовках. Мне лично больше нравится вариант с заголовком, который я назову X-Auth-Token.Так же в моём варианте не предусмотрен какой-либо другой вариант аутентификации, а это значит, что в случае отсутствия заголовка в запросе, приложение должно ответить клиенту, что оно ожидает увидеть этот заголовок.
В рассматриваемом примере система должна аутентифицировать пользователя по токену, переданному в заголовке. Токен для наглядности будет иметь формат username:password, но в реальной жизни он будет являться уникальным ключом.
Нам потребуются следующие зависимости:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>1.2.5.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>1.2.5.RELEASE</version> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>18.0</version> <type>jar</type> </dependency> </dependencies>
Собственно, фильтр:
public class TokenAuthenticationFilter extends GenericFilterBean { private final AuthenticationManager authenticationManager; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Value("${application.tokenAuthentication.header}") private String header; @Value("${application.tokenAuthentication.ignoreFault}") private boolean ignoreFault; public TokenAuthenticationFilter(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; try { String headerValue = httpServletRequest.getHeader(header); if (Strings.isNullOrEmpty(headerValue)) { throw new TokenAuthenticationHeaderNotFound("Header " + header + " is not found.", null); } Authentication authentication = authenticationManager.authenticate(new TokenAuthentication(headerValue)); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(servletRequest, servletResponse); } catch (AuthenticationException authenticationException) { if (!ignoreFault) { authenticationEntryPoint.commence(httpServletRequest, httpServletResponse, authenticationException); } else { filterChain.doFilter(servletRequest, servletResponse); } } } }
Класс аутентификации:
public class TokenAuthentication extends AbstractAuthenticationToken { private final String token; public TokenAuthentication(String token) { super(null); this.token = token; } public TokenAuthentication(String token, Collection authorities) { super(authorities); this.token = token; } @Override public Object getCredentials() { return token.split(":")[1]; } @Override public Object getPrincipal() { return token.split(":")[0]; } }
Точка входа аутентификации:
public class TokenAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { if (authException instanceof TokenAuthenticationHeaderNotFound) { response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED, authException.getMessage()); } else { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); } } }
Провайдер аутентификации:
public class TokenAuthenticationProvider implements AuthenticationProvider { private static final Logger LOGGER = LoggerFactory.getLogger(TokenAuthenticationProvider.class); private final UserDetailsService userDetailsService; public TokenAuthenticationProvider(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { TokenAuthentication tokenAuthentication = (TokenAuthentication) authentication; String token = (String) tokenAuthentication.getPrincipal(); UserDetails userDetails = userDetailsService.loadUserByUsername(token.split(":")[0]); if (userDetails == null) { throw new UsernameNotFoundException("Unknown token"); } tokenAuthentication.setAuthenticated(true); tokenAuthentication.setDetails(userDetails); return authentication; } @Override public boolean supports(Class authentication) { return authentication == TokenAuthentication.class; } }
Конфигурация:
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests().anyRequest().authenticated() .and() .httpBasic().disable() .formLogin().disable() .csrf().disable() .addFilterBefore(tokenAuthenticationFilter(authenticationManager()), BasicAuthenticationFilter.class); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user").password("password").roles("USER") .and() .withUser("admin").password("adminPassword").roles("USER", "ADMIN"); auth.authenticationProvider(tokenAuthenticationProvider(auth.getDefaultUserDetailsService())); } @Bean public AuthenticationEntryPoint tokenAuthenticationEntryPoint() { return new TokenAuthenticationEntryPoint(); } @Bean public AuthenticationProvider tokenAuthenticationProvider(UserDetailsService userDetailsService) { return new TokenAuthenticationProvider(userDetailsService); } @Bean public Filter tokenAuthenticationFilter(AuthenticationManager authenticationManager) { return new TokenAuthenticationFilter(authenticationManager); } }
Поскольку мне интересно видеть в проекте только собственную схему аутентификации, другие схемы отключены (BASIC, form-based), так же используются сессии без сохранения состояния, что вполне логично для REST-бекенда.
Теперь при отправке HTTP-запроса с заголовком X-Auth-Token: user:password будет происходить аутентификация пользователя в системе. Для пущей кастомизации можно (а в большинстве случаев нужно) написать свои реализации UserDetails и UserDetailsService. Хотя в случае с передачей токена, являющегося уникальным ключом, по которому можно получить пользователя, нужно будет реализовывать свой сервис, аналогичный UserDetailsService, но загружающий пользователя по токену, а не по имени пользователя.
А в случае, когда токен является зашифрованной строкой, содержащей логин и пароль пользователя, можно даже обойтись без собственной реализации Authentication - достаточно будет использовать стандартную реализацию UsernamePasswordAuthenticationToken.
В следующий раз попробую написать пример многофакторной авторизации на основе Spring Security.