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

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

  • Настроить Spring Security + базу данных.

  • Создать сервер авторизации.

  • Создать сервер ресурсов.

  • Получить access token и refresh token.

  • Получить защищенный ресурс с помощью access token.

Для наглядности мы объединим сервер авторизации и сервер ресурсов в один проект. В качестве типа гранта будем использовать пароль (для хэширования паролей будем использовать Bcrypt).

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

Введение

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

Роли OAuth

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

  • Владелец ресурса (Пользователь) — субъект, способный предоставить доступ к защищенному ресурсу (например, конечному пользователю).

  • Сервер ресурсов (API-сервер) — сервер, на котором размещены защищенные ресурсы, способный принимать ответы на запросы защищенных ресурсов с использованием маркеров доступа.

  • Клиент — приложение, выполняющее запросы к защищенным ресурсам от имени владельца ресурса и с его авторизацией.

  • Сервер авторизации — сервер, выдающий access token клиенту после успешной аутентификации и авторизации владельца ресурса.

Типы грантов (Grants)

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

  • Authorization Code для приложений, работающих на веб-сервере, браузерных и мобильных приложениях;

  • Password для входа в систему с помощью имени пользователя и пароля (только для собственных приложений);

  • Client credentials для доступа к приложению без присутствия клиента;

  • PKCE как замена Implict, для Authorization Code без секрета клиента;

  • Device Code для авторизации устройств без браузера или с ограниченным вводом;

  • Refresh Token для получения нового acсess token при окончании срока действия.

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

Приложение

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

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

Наш основной бизнес-объект — это Компания:

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

  • COMPANY_CREATE

  • COMPANY_READ

  • COMPANY_UPDATE

  • COMPANY_DELETE

  • DEPARTMENT_CREATE

  • DEPARTMENT_READ

  • DEPARTMENT_UPDATE

  • DEPARTMENT_DELETE

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

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

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

Предположим, что мы хотим назвать наш сервер ресурсов 'resource-server-rest-api'. Для этого сервера определим два клиента:

  • spring-security-oauth2-read-client (авторизованные типы грантов: read) 

  • spring-security-oauth2-read-write-client (авторизованные типы грантов: read, write)

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);
    }
}

Кодировщики паролей

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

  • Пароль клиента 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:

@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);
    }
}

Обратите внимание, мы:

  • Определили бин(bean) TokenStore, чтобы Spring мог использовать нашу базу данных для операций с токенами.

  • Переопределили методы configure для использования пользовательской реализации UserDetailsService, бина AuthenticationManager и кодировщика паролей клиента OAuth2.

  • Определили бин-обработчик проблем с аутентификацией.

  • Добавили две конечных точки для проверки токенов (/oauth/check_token и /oauth/token_key), переопределив метод configure (AuthorizationServerSecurityConfigureroauthServer).

Сервер ресурсов

Сервер ресурсов обслуживает ресурсы, защищенные токеном OAuth2.

Spring OAuth2 предоставляет фильтр аутентификации, обеспечивающий безопасность. Аннотация @EnableResourceServer подключает фильтр Spring Security, который аутентифицирует запросы по входящему токену OAuth2.

@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);
    }
}

Метод configure(HttpSecurity http) настраивает правила доступа и requestMatcher (для сопоставления адресов) для защищенных ресурсов, используя класс HttpSecurity. Мы защищаем URL-адреса /secured/*. Стоит отметить, что для любого POST-запроса необходимы права доступа 'write'.

Проверим, работает ли наша конечная точка аутентификации:

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

Ниже приведены скриншоты из Postman:

и

У вас должен получиться аналогичный результат

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

Конфигурация правил доступа

Для защиты доступа к объектам Company и Department на сервисном уровне необходимо использовать аннотацию @PreAuthorize.

@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);
    }
}

Проверим, работает ли наша конечная точка:

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

Посмотрим, что произойдет, если мы авторизуемся с помощью 'spring-security-oauth2-read-client' — у этого клиента заданы только права доступа 'read'.

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

Для такого запроса:

  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
}'

Мы получаем следующую ошибку:

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

Заключение

В этой статье мы показали аутентификацию OAuth2 с помощью Spring. Права доступа были определены путем установления прямой связи между User и Authorities. Для доработки этого примера можно создать дополнительную сущность - Role — для улучшения структуры прав доступа.

Исходный код для вышеприведенного листинга можно найти в этом проекте на GitHub.

Еще больше о микросервисах

Разобраться с технологий микросервисов можно на нашем курсе. Здесь мы разберем основные понятия микросервисов, фреймворки для работы. Микросервисы мы будем делать на Java и Kotlin, а практическая часть будет доступна в исходниках Git-репозитория. Узнайте больше о курсе по ссылке: https://slurm.club/3E7WZsk