当前位置:Java -> 使用Spring Security和OAuth2实现安全的Spring REST
在这篇文章中,我们将演示如何在一个示例Spring Boot项目中通过Spring Security + OAuth2来保护REST API端点。客户端和用户凭据将存储在关系型数据库中(针对H2和PostgreSQL数据库引擎准备了示例配置)。为此,我们将需要:
相关教程:设置一个使用PostgreSQL的Spring Boot应用程序
为了简化演示,我们将在同一个项目中组合授权服务器和资源服务器。我们将使用密码作为授权类型(使用BCrypt来加密我们的密码)。
在开始之前,您应该熟悉OAuth2基础知识。
OAuth 2.0规范定义了一种委托协议,用于在网络上传递授权决策给各种基于Web的应用程序和API。OAuth在许多应用程序中使用,包括提供用户认证机制。
REST API作为现代应用程序的基础,促进各种组件之间的数据交换和通信。无论是处理金融交易的银行应用程序,管理敏感患者数据的医疗保健平台,还是处理用户信息的电子商务系统,保护API端点的风险异常高。未经授权的访问、数据泄露和其他安全威胁不仅危及用户隐私,也给组织带来重大的法律和金融风险。
保护REST API端点不仅仅是最佳实践,它在当今网络威胁持续演变的时代是一种至关重要的必需品。未经保护的API可能被利用以获取未经授权的访问、操纵数据或中断服务,这将给企业和用户带来严重后果。
在网络安全复杂的领域中,Spring Security已成为在Java应用程序中实现认证和授权的首选框架。其模块化和可扩展的架构使其非常适合保护REST API,为开发人员提供了一个强大的工具集来强制执行访问控制并保护资源。
另一方面,OAuth2通过引入一种标准化的委托访问协议来解决授权的特定挑战。该协议允许应用程序在不暴露敏感凭据的情况下获取对用户资源的有限访问权限。OAuth2的灵活性使其特别适用于第三方应用程序或服务需要对受保护资源进行受控访问的情景。
通过结合Spring Security和OAuth2,开发人员可以建立强大的防御措施来抵御各种安全威胁。Spring Security的功能范围从用户认证扩展到复杂的授权场景,而OAuth2简化了管理访问令牌和权限的过程,实现了安全且用户友好的体验。
OAuth指定了四种角色:
OAuth 2为不同用例定义了几种“授权类型”。 定义的授权类型包括:
让我们考虑示例应用程序的数据库层和应用程序层。
我们的主要业务对象是Company
:
基于Company
和Department
对象的CRUD操作,我们希望定义以下访问规则:
COMPANY_CREATE
COMPANY_READ
COMPANY_UPDATE
COMPANY_DELETE
DEPARTMENT_CREATE
DEPARTMENT_READ
DEPARTMENT_UPDATE
DEPARTMENT_DELETE
此外,我们想创建一个ROLE_COMPANY_READER
角色。
我们需要在数据库中创建以下表(用于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
(授权授予类型: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提供了两个有用的接口:
为了存储授权数据,我们将定义以下数据模型:
因为我们希望使用一些预加载数据,请看下面的脚本将加载所有权限:
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客户端和用户使用不同的加密,所以我们将定义分开的密码编码器用于加密:
@Configuration
public class Encoders {
@Bean
public PasswordEncoder oauthClientPasswordEncoder() {
return new BCryptPasswordEncoder(4);
}
@Bean
public PasswordEncoder userPasswordEncoder() {
return new BCryptPasswordEncoder(8);
因为我们想要从数据库中获取用户和权限,我们需要告诉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);
}
}
为了区分服务和存储库层,我们将创建具有JPA存储库的UserRepository
:
@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 Boot 3.0用于Spring Data JPA的更多信息
@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);
}
}
首先,我们必须实现以下组件:
授权服务器负责验证用户身份并提供令牌。
Spring Security处理Authentication,Spring Security OAuth2处理Authorization。要配置和启用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
bean,以便让Spring知道使用数据库进行令牌操作UserDetailsService
实现,AuthenticationManager
bean,以及OAuth2客户端的密码编码器AuthorizationServerSecurityConfigureroauthServer
)方法,为检查令牌启用了两个端点(/oauth/check_token和/oauth/token_key)资源服务器提供受OAuth2令牌保护的资源
Spring OAuth2提供了一个认证过滤器来处理保护。 @EnableResourceServer注解启用了一个Spring安全过滤器,通过传入的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)
方法使用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
进行授权会发生什么-此客户端只定义了读取作用域。
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"
}
在这篇博文中,我们展示了Spring中的OAuth2认证。访问权限是直接建立User
和Authorities
之间的直接连接来定义的。为了增强这个例子,我们可以添加一个附加实体-Role
-以改进访问权限的结构。
上述列表的源代码可以在GitHub项目中找到。
推荐阅读: ChatGpt会对生活产生哪些影响
本文链接: 使用Spring Security和OAuth2实现安全的Spring REST