Spring Security功能多,组件抽象程度高,配置方式多样,导致了Spring Security强大且复杂的特性。Spring Security的学习成本几乎是Spring家族中最高的,Spring Security的精良设计值得我们学习,但是结合实际复杂的业务场景,我们不但需要理解Spring Security的扩展方式还需要去理解一些组件的工作原理和流程(否则怎么去继承并改写需要改写的地方呢?),这又带来了更高的门槛,因此,在决定使用Spring Security搭建整套安全体系(授权、认证、权限、审计)之前还是需要考虑一下将来我们的业务会多复杂,我们徒手写一套安全体系来的划算还是使用Spring Security更好。
短短的一篇文章不可能覆盖Spring Security的方方面面,在最近的工作中会比较多接触OAuth2,因此本文以这个维度来简单阐述一下如果使用Spring Security搭建一套OAuth2授权&SSO架构。
OAuth2简介
OAuth2.0是一套授权体系的开放标准,定义了四大角色:
资源拥有者,也就是用户,由用于授予三方应用权限
客户端,也就是三方应用程序,在访问用户资源之前需要用户授权
资源提供者,或者说资源服务器,提供资源,需要实现Token和ClientID的校验,以及做好相应的权限控制
授权服务器,验证用户身份,为客户端颁发Token,并且维护管理ClientID、Token以及用户
其中后三项都可以是独立的程序,在本文的例子中我们会为这三者建立独立的项目。OAuth2.0标准同时定义了四种授权模式,这里介绍最常用的三种,也是后面会演示的三种(在之后的介绍中令牌=Token,码=Code,可能会混合表达):
不管是哪种模式,通用流程如下:
三方网站(或者说客户端)需要先向授权服务器去申请一套接入的ClientID+ClientSecret
用任意一种模式拿到访问Token(流程见下)
拿着访问Token去资源服务器请求资源
资源服务器根据Token查询到Token对应的权限进行权限控制
授权码模式,最标准最安全的模式,适合和外部交互,流程是:
三方网站客户端转到授权服务器,上送ClientID,授权范围Scope、重定向地址RedirectUri等信息
用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)
授权完成后重定向回到之前客户端提供的重定向地址,附上授权码
三方网站服务端通过授权码+ClientID+ClientSecret去授权服务器换取Token(Token含访问Token和刷新Token,访问Token过去后用刷新Token去获得新的访问Token)
你可能会问这个模式为什么这么复杂,为什么安全呢?因为我们不会对外暴露ClientSecret,不会对外暴露访问Token,使用授权码换取Token的过程是服务端进行,客户端拿到的只是一次性的授权码
密码凭证模式,适合内部系统之间使用的模式(客户端是自己人,客户端需要拿到用户帐号密码),流程是:
用户提供帐号密码给客户端
客户端凭着用户的帐号密码,以及客户端自己的ClientID+ClientSecret去授权服务器换取Token
客户端模式,适合内部服务端之间使用的模式:
和用户没有关系,不是基于用户的授权
客户端凭着自己的ClientID+ClientSecret去授权服务器换取Token
下面,我们来搭建程序实际体会一下这几种模式。
搭建授权服务器
首先来创建一个父POM,内含三个模块:
    4.0.0
    me.josephzhu
    springsecurity101
    pom
    1.0-SNAPSHOT
    
        org.springframework.boot
        spring-boot-starter-parent
        2.0.6.RELEASE
        
    
    
        springsecurity101-cloud-oauth2-client
        springsecurity101-cloud-oauth2-server
        springsecurity101-cloud-oauth2-userservice
    
    
        UTF-8
        UTF-8
        1.8
    
    
        
            org.projectlombok
            lombok
            true
        
    
    
        
            
                org.springframework.cloud
                spring-cloud-dependencies
                Finchley.SR2
                pom
                import
            
        
    
    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    
    
        
            spring-milestones
            Spring Milestones
            https://repo.spring.io/libs-milestone
            
                false
            
        
    
然后我们创建第一个模块,资源服务器:
    
        springsecurity101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0
    springsecurity101-cloud-oauth2-server
    
        
            org.springframework.cloud
            spring-cloud-starter-oauth2
        
        
            org.springframework.boot
            spring-boot-starter-jdbc
        
        
            mysql
            mysql-connector-java
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.boot
            spring-boot-starter-thymeleaf
        
    
这边我们除了使用了Spring Cloud的OAuth2启动器之外还使用数据访问、Web等依赖,因为我们的资源服务器需要使用数据库来保存客户端的信息、用户信息等数据,我们同时也会使用thymeleaf来稍稍美化一下登录页面。
现在我们来创建一个配置文件application.yml:
server:
  port: 8080
spring:
  application:
    name: oauth2-server
  datasource:
    url: jdbc:mysql://localhost:3306/oauth?useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver
可以看到,我们会使用oauth数据库,授权服务器的端口是8080。
数据库中我们需要初始化一些表:
用户表users:存放用户名密码
授权表authorities:存放用户对应的权限
客户端信息表oauth_client_details:存放客户端的ID、密码、权限、允许访问的资源服务器ID以及允许使用的授权模式等信息
授权码表oauth_code:存放了授权码
授权批准表oauth_approvals:存放了用户授权第三方服务器的批准情况
DDL如下:
-- ----------------------------
-- Table structure for authorities
-- ----------------------------
DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) NOT NULL,
  `authority` varchar(50) NOT NULL,
  UNIQUE KEY `ix_auth_username` (`username`,`authority`),
  CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
  `userId` varchar(256) DEFAULT NULL,
  `clientId` varchar(256) DEFAULT NULL,
  `partnerKey` varchar(32) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresAt` datetime DEFAULT NULL,
  `lastModifiedAt` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(255) NOT NULL,
  `resource_ids` varchar(255) DEFAULT NULL,
  `client_secret` varchar(255) DEFAULT NULL,
  `scope` varchar(255) DEFAULT NULL,
  `authorized_grant_types` varchar(255) DEFAULT NULL,
  `web_server_redirect_uri` varchar(255) DEFAULT NULL,
  `authorities` varchar(255) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) NOT NULL,
  `password` varchar(100) NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
在之后演示的时候会看到这些表中的数据。这里可以看到我们并没有在数据库中创建相应的表来存放访问令牌、刷新令牌,这是因为我们之后的实现会把令牌信息使用JWT来传输,不会存放到数据库中。基本上所有的这些表都是可以自己扩展的,只需要继承实现Spring的一些既有类即可,这里不做展开。
下面,我们创建一个最核心的类用于配置授权服务器:
package me.josephzhu.springsecurity101.cloud.oauth2.server;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.approval.JdbcApprovalStore;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.sql.DataSource;
import java.util.Arrays;
@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private DataSource dataSource;
    @Autowired
    private AuthenticationManager authenticationManager;
    /**
     * 代码1
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }
    /**
     * 代码2
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients().passwordEncoder(NoOpPasswordEncoder.getInstance());
    }
    /**
     * 代码3
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(
                Arrays.asList(tokenEnhancer(), jwtTokenEnhancer()));
        endpoints.approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManager);
    }
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtTokenEnhancer());
    }
    @Bean
    public JdbcApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new CustomTokenEnhancer();
    }
    @Bean
    protected JwtAccessTokenConverter jwtTokenEnhancer() {
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt"));
        return converter;
    }
    /**
     * 代码4
     */
    @Configuration
    static class MvcConfig implements WebMvcConfigurer {
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("login").setViewName("login");
        }
    }
}
分析下这个类:
首先我们可以看到,我们需要通过注解@EnableAuthorizationServer来开启授权服务器
代码片段1中,我们配置了使用数据库来维护客户端信息,当然在各种Demo中我们经常看到的是在内存中维护客户端信息,通过配置直接写死在这里,对于实际的应用我们一般都会用数据库来维护这个信息,甚至还会建立一套工作流来允许客户端自己申请ClientID
代码片段2中,针对授权服务器的安全,我们干了两个事情,首先打开了验证Token的访问权限(以便之后我们演示),然后允许ClientSecret明文方式保存并且可以通过表单提交(而不仅仅是Basic Auth方式提交),之后会演示到这个
代码片段3中,我们干了几个事情:
配置我们的Token存放方式不是内存方式、不是数据库方式、不是Redis方式而是JWT方式,JWT是Json Web Token缩写也就是使用JSON数据格式包装的Token,由.句号把整个JWT分隔为头、数据体、签名三部分,JWT保存Token虽然易于使用但是不是那么安全,一般用于内部,并且需要走HTTPS+配置比较短的失效时间
配置了JWT Token的非对称加密来进行签名
配置了一个自定义的Token增强器,把更多信息放入Token中
配置了使用JDBC数据库方式来保存用户的授权批准记录
代码片段4中,我们配置了登录页面的视图信息(其实可以独立一个配置类更规范)
针对刚才的代码,我们需要补充一些东西到资源目录下,首先需要在资源目录下创建一个templates文件夹然后创建一个login.html登录模板: