[Перевод] Безопасный 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 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 для шифрования:
@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 {
@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 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