Рубрики
Без рубрики

Пружинная защита с JWT

Простой учебник, который покажет вам, как использовать Spring Security с JWT. С тегами java, spring, безопасность, jwt.

Поведение Spring Security по умолчанию легко использовать для стандартного веб-приложения. Он использует аутентификацию и сеансы на основе файлов cookie. Кроме того, он автоматически обрабатывает токены CSRF для вас (чтобы предотвратить атаки “человек посередине”). В большинстве случаев вам просто нужно установить права авторизации для определенных маршрутов, метод извлечения пользователя из базы данных и все.

С другой стороны, вам, вероятно, не понадобится полный сеанс, если вы создаете только API REST, который будет использоваться с внешними службами или вашим SPA/мобильным приложением. Здесь появляется JWT (веб–токен JSON) – небольшой токен с цифровой подписью. Вся необходимая информация может храниться в токене, поэтому ваш сервер может быть без сеанса.

JWT необходимо подключать к каждому HTTP-запросу, чтобы сервер мог авторизовать ваших пользователей. Есть несколько вариантов отправки токена. Например, в качестве параметра URL или в заголовке авторизации HTTP с использованием схемы носителя:

Authorization: Bearer 

Веб-токен JSON содержит три основные части:

  1. Заголовок – обычно включает тип токена и алгоритм хэширования.
  2. Полезная нагрузка – обычно включает данные о пользователе и для которого выдан токен.
  3. Подпись – используется для проверки, не было ли сообщение изменено по пути.

Пример токена

Токен JWT из заголовка авторизации, вероятно, будет выглядеть следующим образом:

Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA

Как вы можете видеть, есть три части, разделенные запятой – заголовок, утверждения и подпись. Заголовок и полезная нагрузка являются объектами JSON в кодировке Base64.

Заголовок:

{
  "typ": "JWT",
  "alg": "HS512"
}

Претензии/Полезная нагрузка:

{
  "iss": "secure-api",
  "aud": "secure-app",
  "sub": "user",
  "exp": 1548242589,
  "rol": [
    "ROLE_USER"
  ]
}

Пример приложения

В следующем примере мы создадим простой API с 2 маршрутами – один общедоступный и один только для авторизованных пользователей.

Мы будем использовать страницу start.spring.io чтобы создать скелет нашего приложения и выбрать Безопасность и веб-зависимости. Остальные параметры зависят от ваших предпочтений.

Поддержка JWT для Java обеспечивается библиотекой JJWT поэтому нам также необходимо добавить следующие зависимости к pom.xml файл:


    io.jsonwebtoken
    jjwt-api
    0.10.5


    io.jsonwebtoken
    jjwt-impl
    0.10.5
    runtime


    io.jsonwebtoken
    jjwt-jackson
    0.10.5
    runtime

Контроллеры

Контроллеры в наших примерах приложений будут максимально простыми. Они просто вернут сообщение или код ошибки HTTP 403 в случае, если пользователь не авторизован.

@RestController
@RequestMapping("/api/public")
public class PublicController {

    @GetMapping
    public String getMessage() {
        return "Hello from public API controller";
    }
}
@RestController
@RequestMapping("/api/private")
public class PrivateController {

    @GetMapping
    public String getMessage() {
        return "Hello from private API controller";
    }
}

Фильтры

Во-первых, мы определим некоторые повторно используемые константы и значения по умолчанию для генерации и проверки JWTS.

Примечание: Вы не должны жестко кодировать ключ подписи JWT в коде вашего приложения (мы пока проигнорируем это в примере). Вы должны использовать переменную среды или файл .properties. Кроме того, ключи должны иметь соответствующую длину. Например, алгоритму HS512 требуется ключ размером не менее 512 байт.

public final class SecurityConstants {

    public static final String AUTH_LOGIN_URL = "/api/authenticate";

    // Signing key for HS512 algorithm
    // You can use the page http://www.allkeysgenerator.com/ to generate all kinds of keys
    public static final String JWT_SECRET = "n2r5u8x/A%D*G-KaPdSgVkYp3s6v9y$B&E(H+MbQeThWmZq4t7w!z%C*F-J@NcRf";

    // JWT token defaults
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String TOKEN_TYPE = "JWT";
    public static final String TOKEN_ISSUER = "secure-api";
    public static final String TOKEN_AUDIENCE = "secure-app";

    private SecurityConstants() {
        throw new IllegalStateException("Cannot create instance of static util class");
    }
}

Первый фильтр будет использоваться непосредственно для аутентификации пользователя. Он проверит параметры имени пользователя и пароля по URL-адресу и вызовет диспетчер аутентификации Spring для их проверки.

Если имя пользователя и пароль верны, то фильтр создаст токен JWT и вернет его в заголовке авторизации HTTP.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;

        setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        var username = request.getParameter("username");
        var password = request.getParameter("password");
        var authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain filterChain, Authentication authentication) {
        var user = ((User) authentication.getPrincipal());

        var roles = user.getAuthorities()
            .stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList());

        var signingKey = SecurityConstants.JWT_SECRET.getBytes();

        var token = Jwts.builder()
            .signWith(Keys.hmacShaKeyFor(signingKey), SignatureAlgorithm.HS512)
            .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
            .setIssuer(SecurityConstants.TOKEN_ISSUER)
            .setAudience(SecurityConstants.TOKEN_AUDIENCE)
            .setSubject(user.getUsername())
            .setExpiration(new Date(System.currentTimeMillis() + 864000000))
            .claim("rol", roles)
            .compact();

        response.addHeader(SecurityConstants.TOKEN_HEADER, SecurityConstants.TOKEN_PREFIX + token);
    }
}

Второй фильтр обрабатывает все HTTP-запросы и проверяет, есть ли заголовок авторизации с правильным маркером. Например, если срок действия токена не истек или если ключ подписи правильный.

Если токен действителен, то фильтр добавит данные аутентификации в контекст безопасности Spring.

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private static final Logger log = LoggerFactory.getLogger(JwtAuthorizationFilter.class);

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws IOException, ServletException {
        var authentication = getAuthentication(request);
        if (authentication == null) {
            filterChain.doFilter(request, response);
            return;
        }

        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        var token = request.getHeader(SecurityConstants.TOKEN_HEADER);
        if (StringUtils.isNotEmpty(token) && token.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            try {
                var signingKey = SecurityConstants.JWT_SECRET.getBytes();

                var parsedToken = Jwts.parser()
                    .setSigningKey(signingKey)
                    .parseClaimsJws(token.replace("Bearer ", ""));

                var username = parsedToken
                    .getBody()
                    .getSubject();

                var authorities = ((List) parsedToken.getBody()
                    .get("rol")).stream()
                    .map(authority -> new SimpleGrantedAuthority((String) authority))
                    .collect(Collectors.toList());

                if (StringUtils.isNotEmpty(username)) {
                    return new UsernamePasswordAuthenticationToken(username, null, authorities);
                }
            } catch (ExpiredJwtException exception) {
                log.warn("Request to parse expired JWT : {} failed : {}", token, exception.getMessage());
            } catch (UnsupportedJwtException exception) {
                log.warn("Request to parse unsupported JWT : {} failed : {}", token, exception.getMessage());
            } catch (MalformedJwtException exception) {
                log.warn("Request to parse invalid JWT : {} failed : {}", token, exception.getMessage());
            } catch (SignatureException exception) {
                log.warn("Request to parse JWT with invalid signature : {} failed : {}", token, exception.getMessage());
            } catch (IllegalArgumentException exception) {
                log.warn("Request to parse empty or null JWT : {} failed : {}", token, exception.getMessage());
            }
        }

        return null;
    }
}

Конфигурация безопасности

Последняя часть, которую нам нужно настроить, – это сама безопасность Spring. Конфигурация проста, нам нужно установить всего несколько деталей:

  • Кодировщик паролей – в нашем случае bcrypt
  • CORS конфигурация
  • Менеджер аутентификации – в нашем случае простая аутентификация в памяти, но в реальной жизни вам понадобится что-то вроде UserDetailsService
  • Укажите, какие конечные точки являются безопасными, а какие общедоступными
  • Добавьте наши 2 фильтра в контекст безопасности
  • Отключите управление сеансами – нам не нужны сеансы, поэтому это предотвратит создание файлов cookie сеанса
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/api/public").permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilter(new JwtAuthenticationFilter(authenticationManager()))
            .addFilter(new JwtAuthorizationFilter(authenticationManager()))
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user")
            .password(passwordEncoder().encode("password"))
            .authorities("ROLE_USER");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());

        return source;
    }
}

Тест

Запрос к общедоступному API

GET http://localhost:8080/api/public
HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 32
Date: Sun, 13 Jan 2019 12:22:14 GMT

Hello from public API controller

Response code: 200; Time: 18ms; Content length: 32 bytes

Аутентификация пользователя

POST http://localhost:8080/api/authenticate?username=user&password=password
HTTP/1.1 200 
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDYwNzUsInJvbCI6WyJST0xFX1VTRVIiXX0.yhskhWyi-PgIluYY21rL0saAG92TfTVVVgVT1afWd_NnmOMg__2kK5lcna3lXzYI4-0qi9uGpI6Ul33-b9KTnA
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 13 Jan 2019 12:21:15 GMT



Response code: 200; Time: 167ms; Content length: 0 bytes

Запрос к частному API с токеном

GET http://localhost:8080/api/private
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 33
Date: Sun, 13 Jan 2019 12:22:48 GMT

Hello from private API controller

Response code: 200; Time: 12ms; Content length: 33 bytes

Запрос к частному API без токена

Вы получите сообщение HTTP 403 при вызове защищенной конечной точки без действительного JWT.

GET http://localhost:8080/api/private
HTTP/1.1 403 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 13 Jan 2019 12:27:25 GMT

{
  "timestamp": "2019-01-13T12:27:25.020+0000",
  "status": 403,
  "error": "Forbidden",
  "message": "Access Denied",
  "path": "/api/private"
}

Response code: 403; Time: 28ms; Content length: 125 bytes

Вывод

Цель этой статьи не в том, чтобы показать единственно правильный способ использования JWTS в Spring Security. Это пример того, как вы можете сделать это в своем реальном приложении. Кроме того, я не хотел слишком углубляться в эту тему, поэтому не хватает таких вещей, как обновление токенов, аннулирование и т.д. но я, вероятно, затрону эти темы в будущем.

tl;dr Вы можете найти полный исходный код этого примера API в моем репозитории GitHub .

Оригинал: “https://dev.to/kubadlo/spring-security-with-jwt-3j76”