当前位置:Java -> 使用Spring Security和OAuth2实现安全的Spring REST

使用Spring Security和OAuth2实现安全的Spring REST

在这篇文章中,我们将演示如何在一个示例Spring Boot项目中通过Spring Security + OAuth2来保护REST API端点。客户端和用户凭据将存储在关系型数据库中(针对H2和PostgreSQL数据库引擎准备了示例配置)。为此,我们将需要:

  • 配置Spring Security + 数据库
  • 创建授权服务器
  • 创建资源服务器
  • 获取访问令牌和刷新令牌
  • 使用访问令牌获取受保护的资源

相关教程:设置一个使用PostgreSQL的Spring Boot应用程序

为了简化演示,我们将在同一个项目中组合授权服务器和资源服务器。我们将使用密码作为授权类型(使用BCrypt来加密我们的密码)。

在开始之前,您应该熟悉OAuth2基础知识

OAuth 2.0规范定义了一种委托协议,用于在网络上传递授权决策给各种基于Web的应用程序和API。OAuth在许多应用程序中使用,包括提供用户认证机制。

保护REST API端点的重要性

REST API作为现代应用程序的基础,促进各种组件之间的数据交换和通信。无论是处理金融交易的银行应用程序,管理敏感患者数据的医疗保健平台,还是处理用户信息的电子商务系统,保护API端点的风险异常高。未经授权的访问、数据泄露和其他安全威胁不仅危及用户隐私,也给组织带来重大的法律和金融风险。

保护REST API端点不仅仅是最佳实践,它在当今网络威胁持续演变的时代是一种至关重要的必需品。未经保护的API可能被利用以获取未经授权的访问、操纵数据或中断服务,这将给企业和用户带来严重后果。

为什么选择Spring Security和OAuth2?

在网络安全复杂的领域中,Spring Security已成为在Java应用程序中实现认证和授权的首选框架。其模块化和可扩展的架构使其非常适合保护REST API,为开发人员提供了一个强大的工具集来强制执行访问控制并保护资源。

另一方面,OAuth2通过引入一种标准化的委托访问协议来解决授权的特定挑战。该协议允许应用程序在不暴露敏感凭据的情况下获取对用户资源的有限访问权限。OAuth2的灵活性使其特别适用于第三方应用程序或服务需要对受保护资源进行受控访问的情景。

通过结合Spring Security和OAuth2,开发人员可以建立强大的防御措施来抵御各种安全威胁。Spring Security的功能范围从用户认证扩展到复杂的授权场景,而OAuth2简化了管理访问令牌和权限的过程,实现了安全且用户友好的体验。

OAuth角色

OAuth指定了四种角色:

  • 资源所有者(用户):能够授予对受保护资源的访问权限的实体(例如最终用户)
  • 资源服务器(API服务器):托管受保护资源的服务器,能够使用访问令牌接受和响应受保护资源请求
  • 客户端:代表资源所有者进行受保护资源请求,并经过其授权的应用程序
  • 授权服务器:在成功验证资源所有者并获取授权后向客户端颁发访问令牌的服务器 [使用Spring Security构建OAuth 2.0授权服务器]

授权类型

OAuth 2为不同用例定义了几种“授权类型”。 定义的授权类型包括:

  • 授权码
  • 密码
  • 客户端凭据
  • 隐式

密码授权的整体流程

密码授权的整体流程

应用程序

让我们考虑示例应用程序的数据库层和应用程序层。

业务数据

我们的主要业务对象是Company

主要业务对象:Company

基于CompanyDepartment对象的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(授权授予类型:read
  • spring-security-oauth2-read-write-client(授权授予类型:readwrite
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提供了两个有用的接口:

为了存储授权数据,我们将定义以下数据模型:

Data model to store authorization data

因为我们希望使用一些预加载数据,请看下面的脚本将加载所有权限:

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


为了区分服务和存储库层,我们将创建具有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的更多信息

设置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配置

首先,我们必须实现以下组件:

  • 授权服务器
  • 资源服务器

授权服务器

授权服务器负责验证用户身份并提供令牌。

Authorization server flow

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


需注意的一些重要点 - 我们:

  • 定义了TokenStorebean,以便让Spring知道使用数据库进行令牌操作
  • 覆盖了配置方法,使用自定义的UserDetailsService实现,AuthenticationManager bean,以及OAuth2客户端的密码编码器
  • 为认证问题定义了处理器bean
  • 通过覆盖configure (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的屏幕截图:

Postman截图:授权

以及:

Postman截图:主体

您应该会得到类似以下的响应:

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


访问规则配置

我们决定在服务层保护CompanyDepartment对象的访问。我们必须使用@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认证。访问权限是直接建立UserAuthorities之间的直接连接来定义的。为了增强这个例子,我们可以添加一个附加实体-Role-以改进访问权限的结构。

上述列表的源代码可以在GitHub项目中找到。

推荐阅读: ChatGpt会对生活产生哪些影响

本文链接: 使用Spring Security和OAuth2实现安全的Spring REST