带Spring Security和OAuth2的Secure


在这篇文章中,我们将在一个示例Spring引导项目上演示Spring Security+OAuth2用于保护REST API端点。客户端和用户凭证将存储在关系数据库中(为H2和PostgreSQL数据库引擎准备的示例配置)。要做到这一点,我们必须:

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

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

在你开始之前,你应该先熟悉一下OAuth2 fundamentals

介绍

OAuth2.0规范定义了一个委托协议,该协议对于在支持Web的应用程序和API的网络上传递授权决策非常有用。OAuth用于各种各样的应用程序,包括为用户身份验证提供机制。

OAuth角色

OAuth指定四个角色:

  • 资源所有者(用户)-能够授予对受保护资源的访问权的实体(例如最终用户)。
  • 资源服务器(API服务器)-托管受保护资源的服务器,能够使用访问令牌接受对受保护资源请求的响应。
  • 客户端--代表资源所有者并经其授权发出受保护资源请求的应用程序。
  • 授权服务器--在成功验证资源所有者并获得授权后,向客户端颁发访问令牌的服务器。

授予类型

OAuth2为不同的用例提供了几种“授权类型”。定义的授予类型包括:

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

密码授予的总体流程:

应用程序

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

业务数据

我们的主要经营目标是Company

基于公司和部门对象的CRUD操作,我们希望定义以下访问规则:

  • COMPANY_CREATE

  • Company_Read

  • Company_Update

  • COMPANY_DELETE

  • 部门创建

  • Department_Read

  • 部门更新

  • Department_Delete

此外,我们希望创建ROLE_COMPANY_READER角色。

OAuth2客户端设置

我们需要在数据库中创建以下表(用于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(授权授予类型:读,写)
    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轮)。

    权限和用户设置

    SpringSecurity提供了两个有用的接口:

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

    因为我们想要来一些预加载的数据,下面是将加载所有权限的脚本:

    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。为了演示data company的操作,创建了以下端点:

    @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客户端和用户使用不同的加密,因此我们将为加密定义单独的密码编码器:

    • 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安全配置

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

    设置弹簧安全

    @EnableWebSecurityannotation和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配置

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

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

    授权服务器

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

    SpringSecurity处理身份验证,SpringSecurity OAuth2处理授权。要配置和启用OAuth2.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知道如何使用数据库进行令牌操作。
    • 重写configure方法以使用自定义UserDetailsService执行,AuthenticationManagerbean和OAuth2客户端的密码编码器。
    • 为身份验证问题定义了处理程序bean。
    • 为检查令牌启用了两个终结点(/oauth/check_token/oauth/token_key)通过重写配置(AuthorizationServerSecurityConfigureroauthServer)方法。

    资源服务器

    资源服务器为受OAuth2令牌保护的资源提供服务。

    SpringOAuth2提供了一个处理保护的身份验证过滤器。这个@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

    下面是来自《邮差》的截图:

    您应该得到类似于以下内容的响应:

    {
        "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身份验证。访问权限是直接定义的--通过在用户和权威之间建立直接联系。为了增强这个示例,我们可以添加一个额外的实体-角色-来改进访问权限的结构。

    上面清单的源代码可以在GitHub项目。