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

в 19:09, , рубрики: authentication, java, Spring Security, token

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

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

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

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

Автор: guestfromEarth

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js