Краткая предыстория
Один из моих проектов представлялет собой, по сути, набор 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.