В этом посте мы продемонстрируем 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):
Давайте предположим, что мы хотим назвать ресурсный сервер как «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.