Безопасность REST с JWT с использованием Java и Spring Security

Опубликовано: 2022-03-11

Безопасность

Безопасность — враг удобства, и наоборот. Это утверждение верно для любой системы, виртуальной или реальной, от физического входа в дом до платформ веб-банкинга. Инженеры постоянно пытаются найти правильный баланс для данного варианта использования, склоняясь то в одну, то в другую сторону. Обычно, когда появляется новая угроза, мы движемся в сторону безопасности, а не удобства. Затем мы посмотрим, сможем ли мы восстановить утраченное удобство, не слишком снижая безопасность. Более того, этот порочный круг продолжается вечно.

Spring учебник по безопасности: иллюстрация безопасности и удобства

Безопасность — враг удобства, и наоборот.
Твитнуть

Давайте попробуем изучить состояние безопасности REST сегодня, используя простое руководство по безопасности Spring, чтобы продемонстрировать его в действии.

Службы REST (что означает «передача репрезентативного состояния») начинались как чрезвычайно упрощенный подход к веб-службам, которые имели огромные спецификации и громоздкие форматы, такие как WSDL для описания службы или SOAP для указания формата сообщения. В REST у нас нет ни того, ни другого. Мы можем описать службу REST в текстовом файле и использовать любой желаемый формат сообщения, например JSON, XML или даже обычный текст. Упрощенный подход применялся и к безопасности REST-сервисов; никакой определенный стандарт не навязывает определенный способ аутентификации пользователей.

Хотя службы REST не имеют особых указаний, важным из них является отсутствие состояния. Это означает, что сервер не хранит никакого состояния клиента, например сеансы. Таким образом, сервер отвечает на каждый запрос, как если бы он был первым, сделанным клиентом. Однако даже сейчас во многих реализациях по-прежнему используется аутентификация на основе файлов cookie, которая унаследована от стандартной архитектуры веб-сайта. Подход REST без сохранения состояния делает сеансовые файлы cookie неприемлемыми с точки зрения безопасности, но, тем не менее, они по-прежнему широко используются. Помимо игнорирования обязательного отсутствия гражданства, упрощенный подход стал ожидаемым компромиссом в плане безопасности. По сравнению со стандартом WS-Security, используемым для веб-служб, гораздо проще создавать и использовать службы REST, поэтому удобство превзошло все ожидания. Компромисс — довольно слабая безопасность; перехват сеанса и подделка межсайтовых запросов (XSRF) являются наиболее распространенными проблемами безопасности.

При попытке избавиться от клиентских сеансов с сервера иногда использовались некоторые другие методы, такие как базовая или дайджест-аутентификация HTTP. Оба используют заголовок Authorization для передачи учетных данных пользователя с добавлением некоторой кодировки (HTTP Basic) или шифрования (HTTP Digest). Конечно, они содержали те же недостатки, что и веб-сайты: HTTP Basic приходилось использовать поверх HTTPS, поскольку имя пользователя и пароль отправляются в легко обратимой кодировке base64, а HTTP Digest заставлял использовать устаревшее хеширование MD5, которое оказалось небезопасным.

Наконец, в некоторых реализациях для аутентификации клиентов использовались произвольные токены. Этот вариант кажется лучшим, что у нас есть на данный момент. При правильной реализации он устраняет все проблемы безопасности HTTP Basic, HTTP Digest или сеансовых файлов cookie, прост в использовании и следует шаблону без сохранения состояния.

Однако с такими произвольными токенами мало что связано со стандартами. У каждого поставщика услуг было свое представление о том, что помещать в токен и как его кодировать или шифровать. Потребление услуг от разных провайдеров требовало дополнительного времени на настройку только для того, чтобы адаптироваться к конкретному используемому формату токена. Другие же методы (сессионный cookie, HTTP Basic и HTTP Digest) хорошо известны разработчикам, и почти все браузеры на всех устройствах работают с ними «из коробки». Фреймворки и языки готовы к этим методам, имея встроенные функции для беспрепятственной работы с каждым из них.

JWT-аутентификация

JWT (сокращенно от JSON Web Token) — это отсутствующая стандартизация использования токенов для аутентификации в Интернете в целом, а не только для служб REST. В настоящее время он находится в статусе черновика как RFC 7519. Он надежен и может содержать много информации, но по-прежнему прост в использовании, несмотря на то, что его размер относительно невелик. Как и любой другой токен, JWT можно использовать для передачи удостоверений аутентифицированных пользователей между поставщиком удостоверений и поставщиком услуг (которые не обязательно являются одними и теми же системами). Он также может содержать все требования пользователя, такие как данные авторизации, поэтому поставщику услуг не нужно обращаться к базе данных или внешним системам для проверки ролей пользователей и разрешений для каждого запроса; эти данные извлекаются из токена.

Вот как работает безопасность JWT:

Иллюстрация потока Java JWT

  • Клиенты входят в систему, отправляя свои учетные данные поставщику удостоверений.
  • Поставщик удостоверений проверяет учетные данные; если все в порядке, он извлекает данные пользователя, создает JWT, содержащий сведения о пользователе и разрешения, которые будут использоваться для доступа к службам, а также устанавливает срок действия JWT (который может быть неограниченным).
  • Поставщик удостоверений подписывает и, при необходимости, шифрует JWT и отправляет его клиенту в ответ на первоначальный запрос с учетными данными.
  • Клиент хранит JWT в течение ограниченного или неограниченного периода времени, в зависимости от срока действия, установленного поставщиком удостоверений.
  • Клиент отправляет сохраненный JWT в заголовке авторизации для каждого запроса поставщику услуг.
  • Для каждого запроса поставщик услуг берет JWT из заголовка Authorization и при необходимости расшифровывает его, проверяет подпись и, если все в порядке, извлекает данные пользователя и разрешения. Только на основе этих данных и опять же без поиска дополнительных сведений в базе данных или обращения к поставщику удостоверений он может принять или отклонить запрос клиента. Единственное требование состоит в том, чтобы у поставщика удостоверений и услуг было соглашение о шифровании, чтобы служба могла проверить подпись или даже расшифровать, какая личность была зашифрована.

Этот поток обеспечивает большую гибкость, сохраняя при этом безопасность и простоту разработки. Используя этот подход, легко добавлять новые серверные узлы в кластер поставщика услуг, инициализируя их только с возможностью проверки подписи и расшифровки маркеров, предоставляя им общий секретный ключ. Не требуется репликация сеанса, синхронизация базы данных или связь между узлами. ОТДЫХ во всей красе.

Основное отличие JWT от других произвольных токенов заключается в стандартизации содержимого токена. Другой рекомендуемый подход — отправить токен JWT в заголовке Authorization с использованием схемы Bearer. Содержимое заголовка должно выглядеть следующим образом:

 Authorization: Bearer <token>

Реализация безопасности REST

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

Вместо запуска процесса аутентификации путем перенаправления на страницу входа, когда клиент запрашивает защищенный ресурс, сервер REST выполняет аутентификацию всех запросов, используя данные, доступные в самом запросе, в данном случае токен JWT. Если такая аутентификация не удалась, перенаправление не имеет смысла. REST API просто отправляет ответ HTTP-кода 401 (Unauthorized), и клиенты должны знать, что делать; например, браузер покажет динамический div, чтобы пользователь мог указать имя пользователя и пароль.

С другой стороны, после успешной аутентификации на классических многостраничных веб-сайтах пользователь перенаправляется с помощью HTTP-кода 301 (перемещен навсегда), как правило, на домашнюю страницу или, что еще лучше, на страницу, которую пользователь первоначально запросил, что вызвало процесс аутентификации. С REST опять же это не имеет смысла. Вместо этого мы просто продолжили бы выполнение запроса, как если бы ресурс вообще не был защищен, вернул бы HTTP-код 200 (ОК) и ожидаемое тело ответа.

Пример безопасности Spring

Spring Безопасность REST с JWT и Java

Теперь давайте посмотрим, как мы можем реализовать REST API на основе токенов JWT с помощью Java и Spring, пытаясь при этом повторно использовать поведение Spring Security по умолчанию, где это возможно.

Как и ожидалось, среда Spring Security поставляется со множеством готовых подключаемых классов, которые имеют дело со «старыми» механизмами авторизации: cookie-файлы сеанса, HTTP Basic и HTTP Digest. Однако в нем отсутствует встроенная поддержка JWT, и нам нужно запачкать руки, чтобы заставить его работать. Для более подробного обзора вам следует обратиться к официальной документации Spring Security.

Теперь давайте начнем с обычного определения фильтра Spring Security в web.xml :

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

Обратите внимание, что имя фильтра Spring Security должно быть именно springSecurityFilterChain , чтобы остальная часть конфигурации Spring работала «из коробки».

Далее идет XML-декларация bean-компонентов Spring, связанных с безопасностью. Чтобы упростить XML, мы установим пространство имен по умолчанию для security , добавив xmlns="http://www.springframework.org/schema/security" в корневой элемент XML. Остальная часть XML выглядит так:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) В этой строке мы активируем @PreFilter , @PreAuthorize , @PostFilter , @PostAuthorize для любых компонентов Spring в контексте.
  • (2) Мы определяем конечные точки входа и регистрации, чтобы пропустить безопасность; даже «анонимный» должен уметь выполнять эти две операции.
  • (3) Затем мы определяем цепочку фильтров, применяемую ко всем запросам, добавляя две важные конфигурации: ссылку на точку входа и настройку создания сеанса stateless (мы не хотим, чтобы сеанс создавался в целях безопасности, поскольку мы используем токены для каждого запроса) .
  • (4) Нам не нужна защита csrf потому что наши токены невосприимчивы к ней.
  • (5) Затем мы подключаем наш специальный фильтр аутентификации в предопределенной цепочке фильтров Spring непосредственно перед фильтром входа в форму.
  • (6) Этот компонент является объявлением нашего фильтра аутентификации; поскольку он расширяет Spring AbstractAuthenticationProcessingFilter , нам нужно объявить его в XML, чтобы связать его свойства (автоматическая проводка здесь не работает). Позже мы объясним, что делает фильтр.
  • (7) Обработчик успеха по умолчанию для AbstractAuthenticationProcessingFilter недостаточно хорош для целей REST, поскольку он перенаправляет пользователя на страницу успеха; Вот почему мы установили здесь свои собственные.
  • (8) Объявление провайдера, созданное с помощью authenticationManager , используется нашим фильтром для аутентификации пользователей.

Теперь давайте посмотрим, как мы реализуем определенные классы, объявленные в приведенном выше XML. Обратите внимание, что Spring подключит их для нас. Начнем с самых простых.

Рестаутентикатионэнтрипоинт.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

Как объяснялось выше, этот класс просто возвращает HTTP-код 401 (Unauthorized) при сбое аутентификации, переопределяя перенаправление Spring по умолчанию.

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

Это простое переопределение устраняет стандартное поведение успешной аутентификации (перенаправление на домашнюю или любую другую страницу, запрошенную пользователем). Если вам интересно, почему нам не нужно переопределять AuthenticationFailureHandler , это потому, что реализация по умолчанию не будет перенаправлять куда-либо, если его URL-адрес перенаправления не установлен, поэтому мы просто избегаем установки URL-адреса, что достаточно хорошо.

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

Этот класс является точкой входа в наш процесс аутентификации JWT; фильтр извлекает токен JWT из заголовков запроса и делегирует аутентификацию внедренному AuthenticationManager . Если токен не найден, создается исключение, которое останавливает обработку запроса. Нам также нужно переопределить для успешной аутентификации, потому что поток Spring по умолчанию остановит цепочку фильтров и продолжит перенаправление. Имейте в виду, что нам нужно, чтобы цепочка выполнялась полностью, включая генерацию ответа, как описано выше.

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

В этом классе мы используем Spring AuthenticationManager по умолчанию, но мы внедряем его с нашим собственным AuthenticationProvider , который выполняет фактический процесс аутентификации. Чтобы реализовать это, мы расширяем AbstractUserDetailsAuthenticationProvider , что требует от нас только возврата UserDetails на основе запроса аутентификации, в нашем случае токена JWT, заключенного в класс JwtAuthenticationToken . Если токен недействителен, мы выбрасываем исключение. Однако, если он действителен и расшифровка с помощью JwtUtil успешно, мы извлекаем данные пользователя (как именно мы увидим в классе JwtUtil ), вообще не обращаясь к базе данных. Вся информация о пользователе, включая его роли, содержится в самом токене.

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

Наконец, класс JwtUtil отвечает за преобразование токена в объект User и создание токена из объекта User . Это просто, поскольку для выполнения всей работы JWT используется библиотека jjwt . В нашем примере мы просто сохраняем имя пользователя, идентификатор пользователя и роли пользователя в токене. Мы также могли бы хранить больше произвольных данных и добавлять дополнительные функции безопасности, такие как истечение срока действия токена. Анализ токена используется в AuthenticationProvider , как показано выше. Метод generateToken() вызывается из служб входа и регистрации REST, которые не защищены и не вызывают никаких проверок безопасности и не требуют наличия токена в запросе. В конце он генерирует токен, который будет возвращен клиентам в зависимости от пользователя.

Заключение

Хотя старые, стандартизированные подходы к обеспечению безопасности (файлы cookie сеанса, HTTP Basic и HTTP Digest) также будут работать со службами REST, все они имеют проблемы, которых было бы неплохо избежать, используя лучший стандарт. JWT появляется как раз вовремя, чтобы спасти положение, и, что наиболее важно, он очень близок к тому, чтобы стать стандартом IETF.

Основная сила JWT заключается в обработке аутентификации пользователей без учета состояния и, следовательно, в масштабируемости, при сохранении безопасности с использованием современных стандартов криптографии. Хранение утверждений (ролей и разрешений пользователей) в самом токене дает огромные преимущества в распределенных системных архитектурах, где сервер, выдающий запрос, не имеет доступа к источнику данных аутентификации.