В этой статье мы продемонстрируем пример настройки 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
