Показаны сообщения с ярлыком Spring Security. Показать все сообщения
Показаны сообщения с ярлыком Spring Security. Показать все сообщения

среда, 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.

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