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

В данной статье рассмотрим принцип аутентификации в веб-приложениях на платформе 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 завершена.

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

Спасибо за внимание!

Комментарии (10)


  1. kafeman
    06.03.2016 01:05
    +1

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

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

    Также где-то видел исследование (к сожалению, не могу найти ссылку) о том, что незначительное увеличение размера запроса (которое тут неизбежно) значительно влияет на скорость загрузки страницы.


    1. grossws
      06.03.2016 03:39

      Другой распространенный вариант — короткоживущий access_token (например, минута) плюс refresh_token, и его регулярное обновление через сервис авторизации. А в нём уже для конкретного клиента может храниться условное значение not before, признающее все refresh токены, выданные ранее этого момента недействительными.


  1. 4ertovo4ka
    06.03.2016 03:08
    +1

    Или невнимательно смотрела, или не увидела refresh токена после окончания срока действия.


    1. nKognito
      06.03.2016 10:46
      +1

      В соответствии со спецификацией, определенные grant_type не должны вертать refresh токены вовсе. Например, client_credentials не подразумевает никакого refresh токена, т.к. у клиентского приложения и так есть все данные для получения нового токена


  1. grossws
    06.03.2016 03:32
    +2

    А не проще было взять не самодельный JWT, а что-нибудь типа OpenId Connect (он построен поверх OAuth2). Там уже есть нормальные токены (в формате JWT), access_token обычно короткоживущий, что снимает проблему с отзывом, есть стабильные способы использования (типа заголовка Authentication: Bearer <token>)?

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


    1. guestfromEarth
      06.03.2016 12:03

      Написал здесь

      По поводу doFilter — это так, для наглядности


  1. nKognito
    06.03.2016 10:56
    +1

    Вообще смысл статьи не особо понятен… Показать что вместо готовых решений можно построить свой собственный велосипед? Я конечно за, что каждый программист должен уметь реализовать все с нуля, но раз уж в заголовке прозвучало нечто вроде "с помощью Spring Security", то давайте его и использовать. Например, Spring Security Oauth, отличная реализация как серверной так и клиентской части. Поддерживает оба версии стандарта, отлично интегрируется с Spring Security, благодаря DI, позволяет переопределить практически все реализации пол умолчанию своими собственными.


    1. guestfromEarth
      06.03.2016 12:00

      Была мысль использовать и OAuth, и OpenID, просто у нас не было в этом необходимости, поэтому быстренько собрал такую аутентификцию


      1. nKognito
        06.03.2016 12:57

        Всмысле не было необходимости? Эти библиотеки как раз и сделаны для того, чтобы "быстренько собрать аутентификцию" )))


        1. guestfromEarth
          06.03.2016 13:57

          Что-нибудь попроще нужно было, просто аналог Login Form со своей спецификой. Для серьёзной реализации аутентификации, конечно, лучше с библиотеками, через OAuth и т. п.
          Насчёт велосипедов. Если необходима малая часть возможностей библиотеки, если велосипед не требует больших трудовых и временных затрат и позволяет добиться желаемого, то почему бы и нет. Есть Angular.js, но иногда хочется взять и написать всё просто на JS )))