Использование Spring Boot для защиты OAuth2 и JWT REST

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

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

Рекомендуется начальное знакомство с OAuth2, которое можно получить, прочитав черновик, указанный выше, или ища полезную информацию в Интернете, например эту или эту.

OAuth2 — это структура авторизации, заменяющая первую версию OAuth, созданную еще в 2006 году. Она определяет потоки авторизации между клиентами и одной или несколькими службами HTTP для получения доступа к защищенным ресурсам.

OAuth2 определяет следующие роли на стороне сервера:

  • Владелец ресурса: служба, отвечающая за контроль доступа к ресурсам.
  • Сервер ресурсов: служба, которая фактически предоставляет ресурсы
  • Сервер авторизации: служба, обрабатывающая процесс авторизации, выступающая в качестве посредника между клиентом и владельцем ресурса.

JSON Web Token или JWT — это спецификация для представления утверждений, которые должны быть переданы между двумя сторонами. Утверждения кодируются как объект JSON, используемый в качестве полезной нагрузки зашифрованной структуры, что позволяет снабжать утверждения цифровой подписью или шифровать.

Содержащая структура может быть веб-подписью JSON (JWS) или веб-шифрованием JSON (JWE).

JWT можно выбрать в качестве формата для токенов доступа и обновления, используемых внутри протокола OAuth2.

OAuth2 и JWT приобрели огромную популярность за последние годы из-за следующих особенностей:

  • Предоставляет систему авторизации без сохранения состояния для протокола REST без сохранения состояния.
  • Хорошо вписывается в архитектуру микросервисов, в которой несколько серверов ресурсов могут совместно использовать один сервер авторизации.
  • Содержимым токена легко управлять на стороне клиента благодаря формату JSON.

Однако OAuth2 и JWT не всегда являются лучшим выбором, если для проекта важны следующие соображения:

  • Протокол без сохранения состояния не разрешает отзыв доступа на стороне сервера.
  • Фиксированное время жизни токена усложняет управление длительными сеансами без ущерба для безопасности (например, токен обновления).
  • Требование безопасного хранения токена на стороне клиента

Ожидаемый поток протокола

Хотя одной из основных особенностей OAuth2 является введение уровня авторизации, чтобы отделить процесс авторизации от владельцев ресурсов, для простоты результатом статьи является сборка единого приложения, олицетворяющего всех владельцев ресурсов , сервер авторизации и роли сервера ресурсов . Из-за этого связь будет проходить только между двумя объектами, сервером и клиентом.

Это упрощение должно помочь сосредоточиться на цели статьи, т. е. на настройке такой системы в среде Spring Boot.

Упрощенный поток описан ниже:

  1. Запрос авторизации отправляется от клиента к серверу (действующему как владелец ресурса) с использованием предоставления авторизации пароля.
  2. Токен доступа возвращается клиенту (вместе с токеном обновления)
  3. Затем токен доступа отправляется от клиента к серверу (действующему в качестве сервера ресурсов) при каждом запросе на доступ к защищенному ресурсу.
  4. Сервер отвечает требуемыми защищенными ресурсами

Блок-схема аутентификации

Spring Security и Spring Boot

Прежде всего, краткое введение в стек технологий, выбранных для этого проекта.

Предпочтительным инструментом управления проектами является Maven, но из-за простоты проекта не составит труда переключиться на другие инструменты, такие как Gradle.

В продолжении статьи мы сосредоточимся только на аспектах безопасности Spring, но все фрагменты кода взяты из полностью работающего серверного приложения, исходный код которого доступен в общедоступном репозитории вместе с клиентом, потребляющим свои ресурсы REST.

Spring Security — это фреймворк, предоставляющий почти декларативные службы безопасности для приложений на основе Spring. Его корни восходят к началу Spring, и он организован как набор модулей из-за большого количества охваченных различных технологий безопасности.

Давайте кратко рассмотрим архитектуру Spring Security (более подробное руководство можно найти здесь).

Безопасность в основном связана с аутентификацией , т.е. проверкой личности, и авторизацией , предоставлением прав доступа к ресурсам.

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

Что касается авторизации, выделяют три основных направления:

  1. Авторизация веб-запросов
  2. Авторизация на уровне метода
  3. Доступ к авторизации экземпляров объекта домена

Аутентификация

Базовым интерфейсом является AuthenticationManager , который отвечает за предоставление метода аутентификации. UserDetailsService — это интерфейс, связанный со сбором информации о пользователе, который может быть реализован напрямую или использоваться внутри в случае стандартных методов JDBC или LDAP.

Авторизация

Основной интерфейс — AccessDecisionManager ; которые реализации для всех трех перечисленных выше областей делегируют цепочке AccessDecisionVoter . Каждый экземпляр последнего интерфейса представляет собой связь между Authentication (идентификацией пользователя, именованным принципалом), ресурсом и набором ConfigAttribute , набором правил, которые описывают, как владелец ресурса разрешил доступ к самому ресурсу, возможно, через использование ролей пользователей.

Безопасность веб-приложения реализуется с помощью описанных выше основных элементов в цепочке фильтров сервлетов, а класс WebSecurityConfigurerAdapter представлен как декларативный способ выражения правил доступа к ресурсу.

Безопасность метода сначала обеспечивается наличием @EnableGlobalMethodSecurity(securedEnabled = true) , а затем использованием набора специализированных аннотаций, применяемых к каждому защищаемому методу, таких как @Secured , @PreAuthorize и @PostAuthorize .

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

JWT OAuth2 с Spring Boot

Давайте теперь перейдем к исходной проблеме, чтобы настроить приложение, реализующее OAuth2 и JWT с Spring Boot.

Хотя в мире Java существует несколько серверных библиотек OAuth2 (список можно найти здесь), реализация на основе Spring является естественным выбором, поскольку мы ожидаем найти ее хорошо интегрированной в архитектуру Spring Security и, следовательно, избежать необходимости обрабатывать много низкоуровневых деталей для его использования.

Все зависимости библиотек, связанных с безопасностью, обрабатываются Maven с помощью Spring Boot, который является единственным компонентом, требующим явной версии в файле конфигурации maven pom.xml (т. е. версии библиотек автоматически выводятся Maven, выбирая самые последние версия, совместимая со вставленной версией Spring Boot).

Найдите ниже выдержку из файла конфигурации maven pom.xml , содержащего зависимости, связанные с безопасностью Spring Boot:

 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>

Приложение действует как сервер авторизации OAuth2/владелец ресурса и как сервер ресурсов.

Защищенные ресурсы (как сервер ресурсов) публикуются по пути /api/ , а путь аутентификации (как владелец ресурса/сервер авторизации) сопоставляется с /oauth/token в соответствии с предлагаемым значением по умолчанию.

Структура приложения:

  • пакет security , содержащий конфигурацию безопасности
  • пакет errors , содержащий обработку ошибок
  • users , пакеты glee для ресурсов REST, включая модель, репозиторий и контроллер.

В следующих абзацах описывается конфигурация для каждой из трех упомянутых выше ролей OAuth2. Связанные классы находятся внутри пакета security :

  • OAuthConfiguration , расширение AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration , расширение ResourceServerConfigurerAdapter
  • ServerSecurityConfig , расширение WebSecurityConfigurerAdapter
  • UserService , реализующий UserDetailsService

Настройка владельца ресурса и сервера авторизации

Поведение сервера авторизации включается при наличии аннотации @EnableAuthorizationServer . Его конфигурация объединена с конфигурацией, связанной с поведением владельца ресурса, и обе они содержатся в классе AuthorizationServerConfigurerAdapter .

Используемые здесь конфигурации относятся к:

  • Клиентский доступ (с помощью ClientDetailsServiceConfigurer )
    • Выбор использования хранилища в памяти или на основе JDBC для сведений о клиенте с помощью методов inMemory или jdbc .
    • Базовая аутентификация клиента с использованием clientId и clientSecret (закодированных выбранным bean-компонентом PasswordEncoder )
    • Время действия маркеров доступа и обновления с использованием атрибутов accessTokenValiditySeconds и refreshTokenValiditySeconds
    • Типы грантов, authorizedGrantTypes с использованием атрибута authorGrantTypes
    • Определяет области доступа с помощью метода scopes
    • Определить доступные ресурсы клиента
  • Конечная точка сервера авторизации (с использованием AuthorizationServerEndpointsConfigurer )
    • Определите использование токена JWT с помощью accessTokenConverter .
    • Определите использование интерфейсов UserDetailsService и AuthenticationManager для выполнения аутентификации (как владельца ресурса).
 package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }

В следующем разделе описывается конфигурация, применяемая к серверу ресурсов.

Настройка сервера ресурсов

Поведение сервера ресурсов включается с помощью аннотации @EnableResourceServer а его конфигурация содержится в классе ResourceServerConfiguration .

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

 package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }

Последний элемент конфигурации касается определения безопасности веб-приложений.

Настройка веб-безопасности

Конфигурация веб-безопасности Spring содержится в классе ServerSecurityConfig , который включается с помощью аннотации @EnableWebSecurity . @EnableGlobalMethodSecurity позволяет указать безопасность на уровне метода. Его атрибут proxyTargetClass установлен для того, чтобы это работало для RestController , потому что контроллеры обычно являются классами, не реализующими никаких интерфейсов.

Он определяет следующее:

  • Используемый провайдер аутентификации, определяющий bean-компонент authenticationProvider .
  • Используемый кодировщик паролей, определяющий bean-компонент passwordEncoder
  • Компонент менеджера аутентификации
  • Конфигурация безопасности для опубликованных путей с помощью HttpSecurity
  • Использование пользовательского AuthenticationEntryPoint для обработки сообщений об ошибках за пределами стандартного обработчика ошибок Spring REST ResponseEntityExceptionHandler
 package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }

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

 package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }

Следующий раздел посвящен описанию реализации контроллера REST, чтобы увидеть, как отображаются ограничения безопасности.

REST-контроллер

Внутри контроллера REST мы можем найти два способа применения контроля доступа для каждого метода ресурса:

  • Использование экземпляра OAuth2Authentication переданного Spring в качестве параметра
  • Использование аннотаций @PreAuthorize или @PostAuthorize
 package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }

Заключение

Spring Security и Spring Boot позволяют быстро настроить полный сервер авторизации/аутентификации OAuth2 практически декларативным образом. Установку можно еще больше сократить, настроив свойства клиента OAuth2 непосредственно из файла application.properties/yml , как описано в этом руководстве.

Весь исходный код доступен в этом репозитории GitHub: spring-glee-o-meter. Клиент Angular, который использует опубликованные ресурсы, можно найти в этом репозитории GitHub: glee-o-meter.