среда, 9 сентября 2015 г.

Spring Security и кастомная аутентификация


Краткая предыстория

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

Полезные ссылки

6 комментариев:

  1. Привет. Можешь объяснить как работает этот кусок кода?
    .addFilterBefore(tokenAuthenticationFilter(authenticationManager()), BasicAuthenticationFilter.class);
    для чего нужен BasicAuthenticationFilter.class?

    ОтветитьУдалить
    Ответы
    1. Метод addFilterBefore вставляет фильтр перед указанным, в данном случае инстанс моего TokenAuthenticationFilter располагается в цепочке фильтров перед BasicAuthenticationFilter, т.е. по сути он становится первым в целочке фильтров аутентификации. Так как в моём случае не предполагается использование других методов аутентификации, первая позиция для моего фильтра выглядит наиболее логичной.

      Удалить
  2. Александр, большое спасибо за статью! Из твоего объяснения, почерпнул для себя больше информации, чем из "spring для профессионалов". Люблю, когда все излагается доступным человеческим языком)
    Пиши ещё, у тебя круто получается!

    ОтветитьУдалить
    Ответы
    1. Спасибо большое! Я сейчас не пишу уже в этот блог, т.к. уже есть собственный сайт - https://alexkosarev.name. Плюс делаю стримы на livecoding.tv.

      Удалить
  3. Александр, просветите пожалуйста. Что же находится в TokenAuthenticationHeaderNotFound?
    Очень нужно!

    ОтветитьУдалить
    Ответы
    1. Исключение, расширяющее AuthenticationException:
      https://github.com/alex-kosarev/sandbox-springboot/blob/master/sandbox-springboot-security/src/main/java/name/alexkosarev/sandbox/springboot/security/exceptions/TokenAuthenticationHeaderNotFound.java

      Удалить