Статьи

Безопасный Spring REST с Spring Security и OAuth2

В этом посте мы продемонстрируем Spring Security + OAuth2 для защиты конечных точек API REST в примере проекта Spring Boot. Клиенты и учетные данные пользователя будут храниться в реляционной базе данных (пример конфигурации, подготовленной для ядерных баз данных H2 и PostgreSQL). Для этого нам потребуется:

  • Настройте базу данных Spring Security +.
  • Создайте сервер авторизации.
  • Создайте сервер ресурсов.
  • Получите токен доступа и токен обновления.
  • Получить защищенный ресурс с помощью токена доступа.

Чтобы упростить демонстрацию, мы собираемся объединить Сервер авторизации и Сервер ресурсов в одном проекте. Как тип предоставления, мы будем использовать пароль (мы будем использовать BCrypt для хэширования наших паролей).

Перед началом работы вы должны ознакомиться с основами OAuth2 .

Вступление

Спецификация OAuth 2.0 определяет протокол делегирования, который полезен для передачи решений об авторизации через сеть веб-приложений и API. OAuth используется в самых разных приложениях, включая механизмы аутентификации пользователей.

Роли OAuth

OAuth определяет четыре роли:

  • Владелец ресурса (Пользователь) — объект, способный предоставить доступ к защищенному ресурсу (например, конечному пользователю).
  • Сервер ресурсов (сервер API) — сервер, на котором размещены защищенные ресурсы, способный принимать ответы на запросы защищенных ресурсов с использованием токенов доступа.
  • Клиент — приложение, делающее защищенные запросы ресурсов от имени владельца ресурса и с его авторизацией.
  • Сервер авторизации  — сервер, выдающий клиенту токены доступа после успешной аутентификации владельца ресурса и получения авторизации.

Типы грантов

OAuth 2 предоставляет несколько «типов предоставления» для разных вариантов использования. Определены типы грантов:

  • Код авторизации
  • пароль
  • Учетные данные клиента
  • неявный

Общий поток предоставления пароля:

заявка

Давайте рассмотрим уровень базы данных и уровень приложения для нашего примера приложения.

Бизнес данные

Наш основной бизнес-объект это  Company:

На основе операций CRUD для объектов Компании и Отдела мы хотим определить следующие правила доступа:

  • COMPANY_CREATE

  • COMPANY_READ

  • COMPANY_UPDATE

  • COMPANY_DELETE

  • DEPARTMENT_CREATE

  • DEPARTMENT_READ

  • DEPARTMENT_UPDATE

  • DEPARTMENT_DELETE

Кроме того, мы хотим создать роль ROLE_COMPANY_READER.

Настройка клиента OAuth2

Нам нужно создать следующие таблицы в базе данных (для внутренних целей реализации OAuth2):

  • OAUTH_CLIENT_DETAILS
  • OAUTH_CLIENT_TOKEN
  • OAUTH_ACCESS_TOKEN
  • OAUTH_REFRESH_TOKEN
  • OAUTH_CODE
  • OAUTH_APPROVALS
  • Давайте предположим, что мы хотим назвать ресурсный сервер как «resource-server-rest-api». Для этого сервера мы определяем двух клиентов:

    • spring-security-oauth2-read-client (типы разрешенных прав доступа: чтение)
    • spring-security-oauth2-read-write-client (типы разрешенных прав доступа: чтение, запись)
    INSERT INTO OAUTH_CLIENT_DETAILS(CLIENT_ID, RESOURCE_IDS, CLIENT_SECRET, SCOPE, AUTHORIZED_GRANT_TYPES, AUTHORITIES, ACCESS_TOKEN_VALIDITY, REFRESH_TOKEN_VALIDITY)
     VALUES ('spring-security-oauth2-read-client', 'resource-server-rest-api',
     /*spring-security-oauth2-read-client-password1234*/'$2a$04$WGq2P9egiOYoOFemBRfsiO9qTcyJtNRnPKNBl5tokP7IP.eZn93km',
     'read', 'password,authorization_code,refresh_token,implicit', 'USER', 10800, 2592000);
    
    INSERT INTO OAUTH_CLIENT_DETAILS(CLIENT_ID, RESOURCE_IDS, CLIENT_SECRET, SCOPE, AUTHORIZED_GRANT_TYPES, AUTHORITIES, ACCESS_TOKEN_VALIDITY, REFRESH_TOKEN_VALIDITY)
     VALUES ('spring-security-oauth2-read-write-client', 'resource-server-rest-api',
     /*spring-security-oauth2-read-write-client-password1234*/'$2a$04$soeOR.QFmClXeFIrhJVLWOQxfHjsJLSpWrU1iGxcMGdu.a5hvfY4W',
     'read,write', 'password,authorization_code,refresh_token,implicit', 'USER', 10800, 2592000);

    Обратите внимание, что пароль хешируется с помощью BCrypt (4 раунда).

    Настройка полномочий и пользователей

    Spring Security поставляется с двумя полезными интерфейсами:

    • UserDetails — предоставляет основную информацию о пользователе.
    • GrantedAuthority — представляет полномочия, предоставленные объекту аутентификации.

    Для хранения данных авторизации мы определим следующую модель данных:

    Поскольку мы хотим получить некоторые предварительно загруженные данные, ниже приведен скрипт, который будет загружать все права доступа:

    INSERT INTO AUTHORITY(ID, NAME) VALUES (1, 'COMPANY_CREATE');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (2, 'COMPANY_READ');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (3, 'COMPANY_UPDATE');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (4, 'COMPANY_DELETE');
    
    INSERT INTO AUTHORITY(ID, NAME) VALUES (5, 'DEPARTMENT_CREATE');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (6, 'DEPARTMENT_READ');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (7, 'DEPARTMENT_UPDATE');
    INSERT INTO AUTHORITY(ID, NAME) VALUES (8, 'DEPARTMENT_DELETE');

    Вот скрипт для загрузки всех пользователей и назначенных прав доступа:

    INSERT INTO USER_(ID, USER_NAME, PASSWORD, ACCOUNT_EXPIRED, ACCOUNT_LOCKED, CREDENTIALS_EXPIRED, ENABLED)
      VALUES (1, 'admin', /*admin1234*/'$2a$08$qvrzQZ7jJ7oy2p/msL4M0.l83Cd0jNsX6AJUitbgRXGzge4j035ha', FALSE, FALSE, FALSE, TRUE);
    
    INSERT INTO USER_(ID, USER_NAME, PASSWORD, ACCOUNT_EXPIRED, ACCOUNT_LOCKED, CREDENTIALS_EXPIRED, ENABLED)
      VALUES (2, 'reader', /*reader1234*/'$2a$08$dwYz8O.qtUXboGosJFsS4u19LHKW7aCQ0LXXuNlRfjjGKwj5NfKSe', FALSE, FALSE, FALSE, TRUE);
    
    INSERT INTO USER_(ID, USER_NAME, PASSWORD, ACCOUNT_EXPIRED, ACCOUNT_LOCKED, CREDENTIALS_EXPIRED, ENABLED)
      VALUES (3, 'modifier', /*modifier1234*/'$2a$08$kPjzxewXRGNRiIuL4FtQH.mhMn7ZAFBYKB3ROz.J24IX8vDAcThsG', FALSE, FALSE, FALSE, TRUE);
    
    INSERT INTO USER_(ID, USER_NAME, PASSWORD, ACCOUNT_EXPIRED, ACCOUNT_LOCKED, CREDENTIALS_EXPIRED, ENABLED)
      VALUES (4, 'reader2', /*reader1234*/'$2a$08$vVXqh6S8TqfHMs1SlNTu/.J25iUCrpGBpyGExA.9yI.IlDRadR6Ea', FALSE, FALSE, FALSE, TRUE);
    
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 1);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 2);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 3);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 4);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 5);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 6);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 7);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 8);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (1, 9);
    
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (2, 2);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (2, 6);
    
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (3, 3);
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (3, 7);
    
    INSERT INTO USERS_AUTHORITIES(USER_ID, AUTHORITY_ID) VALUES (4, 9);

    Обратите внимание, что пароль хешируется с помощью BCrypt (8 раундов).

    Уровень приложений

    Тестовое приложение разработано в Spring boot + Hibernate + Flyway с открытым REST API. Для демонстрации работы с данными компании были созданы следующие конечные точки:

    @RestController
    @RequestMapping("/secured/company")
    public class CompanyController {
    
        @Autowired
        private CompanyService companyService;
    
        @RequestMapping(method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public @ResponseBody
        List<Company> getAll() {
            return companyService.getAll();
        }
    
        @RequestMapping(value = "/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public @ResponseBody
        Company get(@PathVariable Long id) {
            return companyService.get(id);
        }
    
        @RequestMapping(value = "/filter", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public @ResponseBody
        Company get(@RequestParam String name) {
            return companyService.get(name);
        }
    
        @RequestMapping(method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public ResponseEntity<?> create(@RequestBody Company company) {
            companyService.create(company);
            HttpHeaders headers = new HttpHeaders();
            ControllerLinkBuilder linkBuilder = linkTo(methodOn(CompanyController.class).get(company.getId()));
            headers.setLocation(linkBuilder.toUri());
            return new ResponseEntity<>(headers, HttpStatus.CREATED);
        }
    
        @RequestMapping(method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public void update(@RequestBody Company company) {
            companyService.update(company);
        }
    
        @RequestMapping(value = "/{id}", method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE)
        @ResponseStatus(value = HttpStatus.OK)
        public void delete(@PathVariable Long id) {
            companyService.delete(id);
        }
    }

    PasswordEncoders

    Поскольку мы будем использовать разные шифрования для клиента и пользователя OAuth2, мы определим отдельные кодеры паролей для шифрования:

    • Пароль клиента OAuth2 — BCrypt (4 раунда)
    • Пароль пользователя — BCrypt (8 раундов)
    @Configuration
    public class Encoders {
    
        @Bean
        public PasswordEncoder oauthClientPasswordEncoder() {
            return new BCryptPasswordEncoder(4);
        }
    
        @Bean
        public PasswordEncoder userPasswordEncoder() {
            return new BCryptPasswordEncoder(8);

    Конфигурация Spring Security

    Предоставить UserDetailsService

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

    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        @Autowired
        private UserRepository userRepository;
    
        @Override
        @Transactional(readOnly = true)
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userRepository.findByUsername(username);
    
            if (user != null) {
                return user;
            }
    
            throw new UsernameNotFoundException(username);
        }
    }

    Чтобы разделить слои сервиса и репозитория, мы создадим  UserRepository с помощью репозитория JPA:

    @Repository
    public interface UserRepository extends JpaRepository<User, Long> {
    
        @Query("SELECT DISTINCT user FROM User user " +
                "INNER JOIN FETCH user.authorities AS authorities " +
                "WHERE user.username = :username")
        User findByUsername(@Param("username") String username);
    }

    Настройка Spring Security

    @EnableWebSecurity аннотаций и WebSecurityConfigurerAdapter работают вместе , чтобы обеспечить безопасность применения. @Order Аннотаций используется для указания WebSecurityConfigurerAdapter следует считать первым.

    @Configuration
    @EnableWebSecurity
    @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
    @Import(Encoders.class)
    public class ServerSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Autowired
        private PasswordEncoder userPasswordEncoder;
    
        @Override
        @Bean
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(userDetailsService).passwordEncoder(userPasswordEncoder);
        }
    }

    Конфигурация OAuth2

    Прежде всего, мы должны реализовать следующие компоненты:

    • Сервер авторизации
    • Ресурсный сервер

    Сервер авторизации

    Сервер авторизации отвечает за проверку личности пользователя и предоставление токенов.

    Spring Security обрабатывает аутентификацию, а Spring Security OAuth2 обрабатывает авторизацию. Чтобы настроить и включить сервер авторизации OAuth 2.0, мы должны использовать аннотацию @EnableAuthorizationServer .

    @Configuration
    @EnableAuthorizationServer
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    @Import(ServerSecurityConfig.class)
    public class AuthServerOAuth2Config extends AuthorizationServerConfigurerAdapter {
    
        @Autowired
        @Qualifier("dataSource")
        private DataSource dataSource;
    
        @Autowired
        private AuthenticationManager authenticationManager;
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Autowired
        private PasswordEncoder oauthClientPasswordEncoder;
    
        @Bean
        public TokenStore tokenStore() {
            return new JdbcTokenStore(dataSource);
        }
    
        @Bean
        public OAuth2AccessDeniedHandler oauthAccessDeniedHandler() {
            return new OAuth2AccessDeniedHandler();
        }
    
        @Override
        public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
            oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()").passwordEncoder(oauthClientPasswordEncoder);
        }
    
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients.jdbc(dataSource);
        }
    
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
            endpoints.tokenStore(tokenStore()).authenticationManager(authenticationManager).userDetailsService(userDetailsService);
        }
    }

    Некоторые важные моменты. Мы:

    • Определил  TokenStore бин, чтобы Spring мог использовать базу данных для операций с токенами.
    • Overrode the configure methods to use the custom UserDetailsService implementation, AuthenticationManager bean, and OAuth2 client’s password encoder.
    • Defined handler bean for authentication issues.
    • Enabled two endpoints for checking tokens (/oauth/check_token and /oauth/token_key) by overriding the configure (AuthorizationServerSecurityConfigureroauthServer) method.

    Resource Server

    A Resource Server serves resources that are protected by the OAuth2 token.

    Spring OAuth2 provides an authentication filter that handles protection. The @EnableResourceServer annotation enables a Spring Security filter that authenticates requests via an incoming OAuth2 token.

    @Configuration
    @EnableResourceServer
    public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
    
        private static final String RESOURCE_ID = "resource-server-rest-api";
        private static final String SECURED_READ_SCOPE = "#oauth2.hasScope('read')";
        private static final String SECURED_WRITE_SCOPE = "#oauth2.hasScope('write')";
        private static final String SECURED_PATTERN = "/secured/**";
    
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) {
            resources.resourceId(RESOURCE_ID);
        }
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http.requestMatchers()
                    .antMatchers(SECURED_PATTERN).and().authorizeRequests()
                    .antMatchers(HttpMethod.POST, SECURED_PATTERN).access(SECURED_WRITE_SCOPE)
                    .anyRequest().access(SECURED_READ_SCOPE);
        }
    }

    The configure(HttpSecurity http) method configures the access rules and request matchers (path) for protected resources using the HttpSecurity class. We secure the URL paths /secured/*. It’s worth noting that to invoke any POST method request, the ‘write’ scope is needed.

    Let’s check if our authentication endpoint is working – invoke:

    curl -X POST \
      http://localhost:8080/oauth/token \
      -H 'authorization: Basic c3ByaW5nLXNlY3VyaXR5LW9hdXRoMi1yZWFkLXdyaXRlLWNsaWVudDpzcHJpbmctc2VjdXJpdHktb2F1dGgyLXJlYWQtd3JpdGUtY2xpZW50LXBhc3N3b3JkMTIzNA==' \
      -F grant_type=password \
      -F username=admin \
      -F password=admin1234 \
      -F client_id=spring-security-oauth2-read-write-client

    Below are screenshots from Postman:

    and

    You should get a response similar to the following:

    {
        "access_token": "e6631caa-bcf9-433c-8e54-3511fa55816d",
        "token_type": "bearer",
        "refresh_token": "015fb7cf-d09e-46ef-a686-54330229ba53",
        "expires_in": 9472,
        "scope": "read write"
    }

    Access Rules Configuration

    We decided to secure access to the Company and Department objects on the service layer. We have to use the @PreAuthorize annotation.

    @Service
    public class CompanyServiceImpl implements CompanyService {
    
        @Autowired
        private CompanyRepository companyRepository;
    
        @Override
        @Transactional(readOnly = true)
        @PreAuthorize("hasAuthority('COMPANY_READ') and hasAuthority('DEPARTMENT_READ')")
        public Company get(Long id) {
            return companyRepository.find(id);
        }
    
        @Override
        @Transactional(readOnly = true)
        @PreAuthorize("hasAuthority('COMPANY_READ') and hasAuthority('DEPARTMENT_READ')")
        public Company get(String name) {
            return companyRepository.find(name);
        }
    
        @Override
        @Transactional(readOnly = true)
        @PreAuthorize("hasRole('COMPANY_READER')")
        public List<Company> getAll() {
            return companyRepository.findAll();
        }
    
        @Override
        @Transactional
        @PreAuthorize("hasAuthority('COMPANY_CREATE')")
        public void create(Company company) {
            companyRepository.create(company);
        }
    
        @Override
        @Transactional
        @PreAuthorize("hasAuthority('COMPANY_UPDATE')")
        public Company update(Company company) {
            return companyRepository.update(company);
        }
    
        @Override
        @Transactional
        @PreAuthorize("hasAuthority('COMPANY_DELETE')")
        public void delete(Long id) {
            companyRepository.delete(id);
        }
    
        @Override
        @Transactional
        @PreAuthorize("hasAuthority('COMPANY_DELETE')")
        public void delete(Company company) {
            companyRepository.delete(company);
        }
    }

    Let’s test if our endpoint is working fine:

    curl -X GET \
      http://localhost:8080/secured/company/ \
      -H 'authorization: Bearer e6631caa-bcf9-433c-8e54-3511fa55816d'

    Let’s see what will happen if we authorize with it ‘spring-security-oauth2-read-client’ – this client has only the read scope defined.

    curl -X POST \
      http://localhost:8080/oauth/token \
      -H 'authorization: Basic c3ByaW5nLXNlY3VyaXR5LW9hdXRoMi1yZWFkLWNsaWVudDpzcHJpbmctc2VjdXJpdHktb2F1dGgyLXJlYWQtY2xpZW50LXBhc3N3b3JkMTIzNA==' \
      -F grant_type=password \
      -F username=admin \
      -F password=admin1234 \
      -F client_id=spring-security-oauth2-read-client

    Then for the below request:

      http://localhost:8080/secured/company \
      -H 'authorization: Bearer f789c758-81a0-4754-8a4d-cbf6eea69222' \
      -H 'content-type: application/json' \
      -d '{
        "name": "TestCompany",
        "departments": null,
        "cars": null
    }'

    We are getting the following error:

    {
        "error": "insufficient_scope",
        "error_description": "Insufficient scope for this resource",
        "scope": "write"
    }

    Summary

    In this blog post, we showed OAuth2 authentication with Spring. Access rights were defined straightforward – by establishing a direct connection between User and Authorities. To enhance this example we can add an additional entity – Role  – to improve the structure of the access rights.

    The source code for the above listings can be found in this GitHub project.