Аутентификация с использованием Spring Security и JWT-токенов

    Всем привет! Хабр жив! Данный пост вряд ли соберёт кучу просмотров и комментов, но, надеюсь, немного поможет здоровью хабра.

    В данной статье рассмотрим принцип аутентификации в веб-приложениях на платформе Spring с использованием относительно нового механизма аутентификации — JSON Web Token (JWT). Этот механизм уже обкатан и реализован для многих языков программирования.



    Использование токена позволяет серверу не заботиться о сохранении состояния между запросами (HTTP-сессии), уменьшить количество запросов к БД — необходимые для восстановления данные могут сохраняться в токене. Непосредственно о токене JWT: сервер смешивает полезную нагрузку в формате JSON (заголовок и тело) с секретным ключом и генерирует хэш, прикрепляя его в качестве сигнатуры к полезной нагрузке. Полезная нагрузка кодируется алгоритмом base64Url, поэтому, естественно, не следует передавать в токене секретные данные. Стандартом JWT шифрование полезной нагрузки не предусмотрено. Шифруйте отдельно сами, если хотите, а задача токена — только обеспечить аутентификацию.

    Предполагается, что читатель знаком с основами Spring Secutity. Про него можно прочитать здесь

    1). Генерация токена

    Для своего примера я взял одну из реализаций спецификации JWT. Токен генерируется следующим образом:

    package com.example.security;
    
    import io.jsonwebtoken.JwtBuilder;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SignatureAlgorithm;
    import io.jsonwebtoken.impl.crypto.MacProvider;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.stereotype.Service;
    
    import java.util.*;
    
    @Service
    public class GetTokenServiceImpl implements GetTokenService {
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Override
        public TokenObject getToken(String username, String password) throws Exception {
            if (username == null || password == null)
                return null;
            User user = (User) userDetailsService.loadUserByUsername(username);
            Map<String, Object> tokenData = new HashMap<>();
            if (password.equals(user.getPassword())) {
                tokenData.put("clientType", "user");
                tokenData.put("userID", user.getUserId().toString());
                tokenData.put("username", authorizedUser.getUsername());
                tokenData.put("token_create_date", new Date().getTime());
                Calendar calendar = Calendar.getInstance();
                calendar.add(Calendar.YEAR, 100);
                tokenData.put("token_expiration_date", calendar.getTime());
                JwtBuilder jwtBuilder = Jwts.builder();
                jwtBuilder.setExpiration(calendar.getTime());
                jwtBuilder.setClaims(tokenData);
                String key = "abc123";
                String token = jwtBuilder.signWith(SignatureAlgorithm.HS512, key).compact();
                return token;
            } else {
                throw new Exception("Authentication error");
            }
        }
    
    }
    
    

    В итоге мы получаем строку вида <Заголовок>.<Тело>.<Сигнатура>, которую и отправляем клиенту

    Теперь к Spring Security. Для реализации собственного механизма аутентификации нам необходимо реализовать свой фильтр и менеджер аутентификации.

    2). Реализация фильтра

    Фильтр — это объект класса, реализующего интерфейс javax.servlet.Filter, который перехватывает запросы на определённые URL и выполняет некоторые действия. Если имеется несколько фильтров, то они образуют цепочку фильтров — HTTP-запрос после приёма приложением проходит через эту цепочку. Каждый фильтр в цепочке может обработать запрос, пропустить его к следующим фильтрам в цепочке или не пропустить, сразу отправив ответ клиенту.

    Задача нашего фильтра — передать токен из запроса менеджеру аутентификации и, в случае успешной аутентификации, установить контекст безопасности приложения.

    package com.example.security;
    
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
    import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    public class TokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
        public TokenAuthenticationFilter() {
            super("/rest/**");
            setAuthenticationSuccessHandler((request, response, authentication) ->
            {
                SecurityContextHolder.getContext().setAuthentication(authentication);  
                request.getRequestDispatcher(request.getServletPath() + request.getPathInfo()).forward(request, response);
            });
            setAuthenticationFailureHandler((request, response, authenticationException) -> {
                response.getOutputStream().print(authenticationException.getMessage());
            });
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
                                          throws AuthenticationException, IOException, ServletException {
            String token = request.getHeader("token");
            if (token == null)
                token = request.getParameter("token");
            if (token == null) {
                TokenAuthentication authentication = new TokenAuthentication(null, null);
                authentication.setAuthenticated(false);
                return authentication;
            }
            TokenAuthentication tokenAuthentication = new TokenAuthentication(token);
            Authentication authentication = getAuthenticationManager().authenticate(tokenAuthentication);
            return authentication;
        }
    
        @Override
        public void doFilter(ServletRequest req, ServletResponse res,
                             FilterChain chain) throws IOException, ServletException {
            super.doFilter(req, res, chain);
        }
    }
    


    Мы унаследовались от абстрактного класса org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter, который специально предназначен для аутентификации. При совпадении URL запроса с паттерном "/rest/**" автоматом произойдёт вызов функции attemptAuthentication().
    Также в конструкторе мы установили два хэндлэра — AuthenticationSuccessHandler и AuthenticationFailureHandler. Если attemptAuthentication вернет объект Authentication, то сработает первый хэндлер, второй хэндлэр сработает при выбросе методом attemptAuthentication исключения AuthenticationException.
    Как мы видим, при успешной аутентификации мы устанавливаем контекст безопасности приложения посредством SecurityContextHolder.getContext().setAuthentication(authentication). Установленный таким образом контекст является переменной ThreadLocal, т.е. доступен, пока жив поток работы с клиентом. После установки контекста мы направляем запрос пользователя к сервлету с первоначально запрашиваемым URL.

    3). Менеджер аутентификации.

    Менеджер аутентификации — это объект класса, реализующего интерфейс org.springframework.security.authentication.AuthenticationManager с единственным методом authenticate(). Данному методу нужно передать частично заполненный объект, реализующий интерфейс org.springframework.security.core.Authentication (контекстом безопасности приложения).
    Задача менеджера аутентификации — в случае успешной аутентификации заполнить полностью объект Authentication и вернуть его. При заполнении нужно установить пользователя (principal), его права (authorities), выполнить setAuthenticated(true). В случае неудачи менеджер аутентификации должен выбросить исключение AuthenticationException.

    Приведём пример реализации интерфейса org.springframework.security.core.Authentication:

    package com.example.security;
    
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    import javax.servlet.http.HttpServletRequest;
    import java.util.Collection;
    import java.util.List;
    import java.util.Map;
    
    public class TokenAuthentication implements Authentication {
        private String token;
        private Collection<? extends GrantedAuthority> authorities;
        private boolean isAuthenticated;
        private UserDetails principal;
    
        public TokenAuthentication(String token) {
            this.token = token;
            this.details = request;
        }
    
        public TokenAuthentication(String token, Collection<SimpleGrantedAutority> authorities, boolean isAuthenticated, 
                                                    UserDetails principal) {
            this.token = token;
            this.authorities = authorities;
            this.isAuthenticated = isAuthenticated;
            this.principal = principal;
        }
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorities;
        }
    
        @Override
        public Object getCredentials() {
            return null;
        }
    
        @Override
        public Object getDetails() {
            return details;
        }
    
        @Override
        public String getName() {
            if (principal != null)
                return ((UserDetails) principal).getUsername();
            else
                return null;
        }
    
        @Override
        public Object getPrincipal() {
            return principal;
        }
    
        @Override
        public boolean isAuthenticated() {
            return isAuthenticated;
        }
    
        @Override
        public void setAuthenticated(boolean b) throws IllegalArgumentException {
            isAuthenticated = b;
        }
    
        public String getToken() {
            return token;
        }
    
    }
    
    


    Приведём реализацию менеджера аутентификации:

    package com.example.security;
    
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.impl.DefaultClaims;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.AuthenticationManager;
    import org.springframework.security.authentication.AuthenticationServiceException;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.authentication.AuthenticationServiceException
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.stereotype.Service;
    import org.springframework.security.core.GrantedAuthority;
    import javax.servlet.http.HttpServletRequest;
    import java.util.*;
    import java.util.function.Function;
    import java.util.stream.Collectors;
    
    @Service
    public class TokenAuthenticationManager implements AuthenticationManager {
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            try {
                if (authentication instanceof TokenAuthentication) {
                    TokenAuthentication readyTokenAuthentication = processAuthentication((TokenAuthentication) authentication);
                    return readyTokenAuthentication;
                } else {
                    authentication.setAuthenticated(false);
                    return authentication;
                }
            } catch (Exception ex) {
                if(ex instanceof AuthenticationServiceException)
                   throw ex;
            }
        }
    
        private TokenAuthentication processAuthentication(TokenAuthentication authentication) throws AuthenticationException {
            String token = authentication.getToken();
            String key = "key123";
            DefaultClaims claims;
            try {
                claims = (DefaultClaims) Jwts.parser().setSigningKey(key).parse(token).getBody();
            } catch (Exception ex) {
                throw new AuthenticationServiceException("Token corrupted");
            }
            if (claims.get("TOKEN_EXPIRATION_DATE", Long.class) == null)
                throw new AuthenticationServiceException("Invalid token");
            Date expiredDate = new Date(claims.get("TOKEN_EXPIRATION_DATE", Long.class));
            if (expiredDate.after(new Date())) 
                  return buildFullTokenAuthentication(authentication, claims);
             else 
                throw new AuthenticationServiceException("Token expired date error");      
        }
    
        private TokenAuthentication buildFullTokenAuthentication(TokenAuthentication authentication, DefaultClaims claims) {
            User user = (User) userDetailsService.loadUserByUsername(claims.get("USERNAME", String.class));
            if (user.isEnabled()) {
                Collection<GrantedAutority> authorities = user.getAuthorities();
                TokenAuthentication fullTokenAuthentication = 
                                                 new TokenAuthentication(authentication.getToken(), authorities, true, user);
                return fullTokenAuthentication;
            } else {
                throw new AuthenticationServiceException("User disabled");;
            }
        }
    }
    
    


    4). Как всё это собрать вместе

    Во-первых, нужно установить фильтр. Сделать это можно 2-мя способами

    Первый способ — определить фильтр в файле web.xml нашего приложения

        <filter>
            <filter-name>springSecurityTokenFilter</filter-name>
            <filter-class>com.example.security.TokenAuthenticationFilter</filter-class>
        </filter>
        <filter-mapping>
            <filter-name>springSecurityTokenFilter</filter-name>
            <url-pattern>/rest/**</url-pattern>
        </filter-mapping>
    

    При таком способе в конструкторе фильтра нужно сразу задать менеджер аутентификации, так как экземпляр фильтра не будет доступен в контексте приложения Spring. Если необходимо иметь фильтр или менеджер аутентификации в качестве бинов Spring, нужно воспользоваться вторым способом.

    Второй способ — установка фильтра в конфигурации Spring Security.

    Для примера покажем конфигурацию с использованием Java Config

    package com.example.security;
    
    import com.example.security.RestTokenAuthenticationFilter;
    import com.example.security.TokenAuthenticationManager;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    
    
    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(securedEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        @Qualifier("userDetailsService")
        UserDetailsService userDetailsService;
    
        @Autowired
        TokenAuthenticationManager tokenAuthenticationManager;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .headers().frameOptions().sameOrigin()
                    .and()
                    .addFilterAfter(restTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                    .authorizeRequests()
                    .antMatchers("/rest/*").authenticated()
        }
    
        @Bean(name = "restTokenAuthenticationFilter")
        public RestTokenAuthenticationFilter restTokenAuthenticationFilter() {
            RestTokenAuthenticationFilter restTokenAuthenticationFilter = new RestTokenAuthenticationFilter();
            tokenAuthenticationManager.setUserDetailsService(userDetailsService);
            restTokenAuthenticationFilter.setAuthenticationManager(tokenAuthenticationManager);
            return restTokenAuthenticationFilter;
        }
    }
    
    

    В строке
    .addFilterAfter(restTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
    мы добавили наш фильтр в цепочку фильтров после стандартного фильтра UsernamePasswordAuthenticationFilter.

    На этом основная настройка механизма аутентификации в Spring Security с использованием JSON Web Token завершена.

    Желаю всем успехов!

    Спасибо за внимание!
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 11

      +1
      Хотел применить JWT на одном из проектов. Остановило то, что отдельно взятый токен очень сложно отозвать.

      Из известных мне решений — хранение «черного списка» в базе данных. Но тогда возникает вопрос — почему бы не хранить в этой базе сами сессии?

      Также где-то видел исследование (к сожалению, не могу найти ссылку) о том, что незначительное увеличение размера запроса (которое тут неизбежно) значительно влияет на скорость загрузки страницы.
        0
        Другой распространенный вариант — короткоживущий access_token (например, минута) плюс refresh_token, и его регулярное обновление через сервис авторизации. А в нём уже для конкретного клиента может храниться условное значение not before, признающее все refresh токены, выданные ранее этого момента недействительными.
        +1
        Или невнимательно смотрела, или не увидела refresh токена после окончания срока действия.
          +1
          В соответствии со спецификацией, определенные grant_type не должны вертать refresh токены вовсе. Например, client_credentials не подразумевает никакого refresh токена, т.к. у клиентского приложения и так есть все данные для получения нового токена
          +2
          А не проще было взять не самодельный JWT, а что-нибудь типа OpenId Connect (он построен поверх OAuth2). Там уже есть нормальные токены (в формате JWT), access_token обычно короткоживущий, что снимает проблему с отзывом, есть стабильные способы использования (типа заголовка Authentication: Bearer <token>)?

          Ну и по реализации: TokenAuthenticationFilter бессмысленно переопределяет doFilter.
            0
            Написал здесь

            По поводу doFilter — это так, для наглядности
            +1
            Вообще смысл статьи не особо понятен… Показать что вместо готовых решений можно построить свой собственный велосипед? Я конечно за, что каждый программист должен уметь реализовать все с нуля, но раз уж в заголовке прозвучало нечто вроде "с помощью Spring Security", то давайте его и использовать. Например, Spring Security Oauth, отличная реализация как серверной так и клиентской части. Поддерживает оба версии стандарта, отлично интегрируется с Spring Security, благодаря DI, позволяет переопределить практически все реализации пол умолчанию своими собственными.
              0
              Была мысль использовать и OAuth, и OpenID, просто у нас не было в этом необходимости, поэтому быстренько собрал такую аутентификцию
                0
                Всмысле не было необходимости? Эти библиотеки как раз и сделаны для того, чтобы "быстренько собрать аутентификцию" )))
                  0
                  Что-нибудь попроще нужно было, просто аналог Login Form со своей спецификой. Для серьёзной реализации аутентификации, конечно, лучше с библиотеками, через OAuth и т. п.
                  Насчёт велосипедов. Если необходима малая часть возможностей библиотеки, если велосипед не требует больших трудовых и временных затрат и позволяет добиться желаемого, то почему бы и нет. Есть Angular.js, но иногда хочется взять и написать всё просто на JS )))
                    +1
                    Я так понимаю OAuth не о том…
                    Представим что после успешной аутентикации мы отдаем клиенту JWT токем в котором уж есть все что нужно для последующей авторизации…
                    например браузерному приложению которое общается с бекэндом
                    при этом бэкэнд состоит из 5 микросервисев. В случае с JWT микросервицы сами смогут проверить даныные из JWT больше никуда не обращаясь в свою очередь…
                    Это очень очень круто.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое