springBoot整合spring security+JWT实现单点登录与权限管理前后端分离--筑基中期

写在前面

在前一篇文章当中,我们介绍了springBoot整合spring security单体应用版,在这篇文章当中,我将介绍springBoot整合spring secury+JWT实现单点登录与权限管理。

本文涉及的权限管理模型是基于资源的动态权限管理。数据库设计的表有 user 、role、user_role、permission、role_permission。

单点登录当中,关于访问者信息的存储有多种解决方案。如将其以key-value的形式存储于redis数据库中,访问者令牌中存放key。校验用户身份时,凭借访问者令牌中的key去redis中找value,没找到则返回“令牌已过期”,让访问者去(重新)认证。本文中的demo,是将访问者信息加密后存于token中返回给访问者,访问者携带令牌去访问服务时,服务提供者直接解密校验token即可。两种实现各有优缺点。大家也可以尝试着将本文中的demo的访问者信息存储改造成存在redis中的方式。文末提供完整的代码及sql脚本下载地址。

在进入正式步骤之前,我们需要了解以下知识点。

单点登录SSO

单点登录也称分布式认证,指的是在有多个系统的项目中,用户经过一次认证,即可访问该项目下彼此相互信任的系统。

单点登录流程

给大家画了个流程图

关于JWT

jwt,全称JSON Web Token,是一款出色的分布式身份校验方案。

jwt由三个部分组成

  1. 头部:主要设置一些规范信息,签名部分的编码格式就在头部中声明。
  2. 有效载荷:token中存放有效信息的部分,比如用户名,用户角色,过期时间等,但不适合放诸如密码等敏感数据,会造成泄露。
  3. 签名:将头部与载荷分别采用base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编码,就得到了签名。

jwt生成的Token安全性分析

想要使得token不被伪造,就要确保签名不被篡改。然而,其签名的头部和有效载荷使用base64编码,这与明文无异。因此,我们只能在盐上做手脚了。我们对盐进行非对称加密后,在将token发放给用户。

RSA非对称加密

  1. 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端 。

    • 公钥加密:只有私钥才能解密

    • 私钥加密:私钥或者公钥都能解密

  2. 优缺点:

    • 优点:安全、难以破解

    • 缺点:耗时,但是为了安全,这是可以接受的

SpringSecurity+JWT+RSA分布式认证思路分析

通过之前的学习,我们知道了spring security主要是基于过滤器链来做认证的,因此,如何打造我们的单点登录,突破口就在于spring security中的认证过滤器。

用户认证

在分布式项目当中,现在大多数都是前后端分离架构设计的,因此,我们需要能够接收POST请求的认证参数,而不是传统的表单提交。因此,我们需要修改修
改UsernamePasswordAuthenticationFilter过滤器中attemptAuthentication方法,让其能够接收请求体。

关于spring security的认证流程分析,大家可以参考我上一篇文章《Spring Security认证流程分析--练气后期》。

另外,默认情况下,successfulAuthentication 方法在通过认证后,直接将认证信息放到服务器的session当中就ok了。而我们分布式应用当中,前后端分离,禁用了session。因此,我们需要在认证通过后生成token(载荷内具有验证用户身份必要的信息)返回给用户。

身份校验

默认情况下,BasicAuthenticationFilter过滤器中doFilterInternal方法校验用户是否登录,就是看session中是否有用户信息。在分布式应用当中,我们要修改为,验证用户携带的token是否合法,并解析出用户信息,交给SpringSecurity,以便于后续的授权功能可以正常使用。

实现步骤

(默认大家一已经创建好了数据库)

第一步:创建一个springBoot的project

这个父工程主要做依赖的版本管理。

其pom.xml文件如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <modules>
        <module>common</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <packaging>pom</packaging>
    <groupId>pers.lbf</groupId>
    <artifactId>springboot-springSecurity-jwt-rsa</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <jwt.version>0.10.7</jwt.version>
        <jackson.version>2.11.2</jackson.version>
        <springboot.version>2.3.3.RELEASE</springboot.version>
        <mybatis.version>2.1.3</mybatis.version>
        <mysql.version>8.0.12</mysql.version>
        <joda.version>2.10.5</joda.version>
        <springSecurity.version>5.3.4.RELEASE</springSecurity.version>
        <common.version>1.0.0-SNAPSHOT</common.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>pers.lbf</groupId>
                <artifactId>common</artifactId>
                <version>${common.version}</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
                <version>${springboot.version}</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
                <version>${springboot.version}</version>
            </dependency>
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis.version}</version>
            </dependency>
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>

            <!--jwt所需jar包-->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-api</artifactId>
                <version>${jwt.version}</version>
            </dependency>
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-impl</artifactId>
                <version>${jwt.version}</version>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-jackson</artifactId>
                <version>${jwt.version}</version>
                <scope>runtime</scope>
            </dependency>
<!--            处理日期-->
            <dependency>
                <groupId>joda-time</groupId>
                <artifactId>joda-time</artifactId>
                <version>${joda.version}</version>
            </dependency>
            <!--处理json工具包-->
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>${jackson.version}</version>
            </dependency>
            <!--日志包-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-logging</artifactId>
                <version>${springboot.version}</version>
            </dependency>
            <!--测试包-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <version>${springboot.version}</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-test</artifactId>
                <version>${springSecurity.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>


</project>

第二步:创建三个子模块

其中,common模块作为公共模块存在,提供基础服务,包括token的生成、rsa加密密钥的生成与使用、Json序列化与反序列化。

authentication-service模块提供单点登录服务(用户认证及授权)。

product-service模块模拟一个子系统。它主要负责提供接口调用和校验用户身份。

创建common模块模块

修改pom.xml,添加jwt、json等依赖

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>springboot-springSecurity-jwt-rsa</artifactId>
        <groupId>pers.lbf</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>common</artifactId>

    <dependencies>
        <!--jwt所需jar包-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>joda-time</groupId>
            <artifactId>joda-time</artifactId>
        </dependency>
        <!--处理json工具包-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <!--日志包-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </dependency>
        <!--测试包-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>

    </dependencies>


</project>
创建一个JSON工具类
**json工具类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/2 22:28
 */
public class JsonUtils {

    public static final ObjectMapper MAPPER = new ObjectMapper();
    private static final Logger logger = LoggerFactory.getLogger(JsonUtils.class);


    private JsonUtils() {

    }

    public static String toString(Object obj) {
        if (obj == null) {
            return null;
        }
        if (obj.getClass() == String.class) {
            return (String) obj;
        }
        try {
            return MAPPER.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            logger.error("json序列化出错:" + obj, e);
            return null;
        }
    }

    public static <T> T toBean(String json, Class<T> tClass) {
        try {
            return MAPPER.readValue(json, tClass);
        } catch (IOException e) {
            logger.error("json解析出错:" + json, e);
            return null;
        }
    }

    public static <E> List<E> toList(String json, Class<E> eClass) {
        try {
            return MAPPER.readValue(json, MAPPER.getTypeFactory().constructCollectionType(List.class, eClass));
        } catch (IOException e) {
            logger.error("json解析出错:" + json, e);
            return null;
        }
    }

    public static <K, V> Map<K, V> toMap(String json, Class<K> kClass, Class<V> vClass) {
        try {
            return MAPPER.readValue(json, MAPPER.getTypeFactory().constructMapType(Map.class, kClass, vClass));
        } catch (IOException e) {
            logger.error("json解析出错:" + json, e);
            return null;
        }
    }

    public static <T> T nativeRead(String json, TypeReference<T> type) {
        try {
            return MAPPER.readValue(json, type);
        } catch (IOException e) {
            logger.error("json解析出错:" + json, e);
            return null;
        }
    }
}

创建RSA加密工具类,并生成公钥和密钥文件

​ RsaUtils.java

/**RSA非对称加密工具类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/2 22:27
 */
public class RsaUtils {

    private static final int DEFAULT_KEY_SIZE = 2048;
    
    /**从文件中读取公钥
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:10:15
     * @param filename 公钥保存路径,相对于classpath
     * @return java.security.PublicKey 公钥对象
     * @throws Exception
     * @version 1.0
     */
    public static PublicKey getPublicKey(String filename) throws Exception {
       
        byte[] bytes = readFile(filename);
        return getPublicKey(bytes);
    }

    
    /**从文件中读取密钥
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:12:01
     * @param filename 私钥保存路径,相对于classpath
     * @return java.security.PrivateKey 私钥对象
     * @throws Exception
     * @version 1.0
     */
    public static PrivateKey getPrivateKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPrivateKey(bytes);
        
    }

    /**
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:12:59
     * @param bytes 公钥的字节形式
     * @return java.security.PublicKey 公钥对象
     * @throws Exception
     * @version 1.0
     */
    private static PublicKey getPublicKey(byte[] bytes) throws Exception {
        bytes = Base64.getDecoder().decode(bytes);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);
        
    }

   
    /**获取密钥
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:14:02
     * @param bytes 私钥的字节形式
     * @return java.security.PrivateKey
     * @throws Exception
     * @version 1.0
     */
    private static PrivateKey getPrivateKey(byte[] bytes) throws InvalidKeySpecException, NoSuchAlgorithmException {
        bytes = Base64.getDecoder().decode(bytes);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
        
    }

    /**
     * 根据密文,生存rsa公钥和私钥,并写入指定文件
     *@author 赖柄沣 bingfengdev@aliyun.com
     *@date 2020-09-04 13:14:02
     * @param publicKeyFilename  公钥文件路径
     * @param privateKeyFilename 私钥文件路径
     * @param secret             生成密钥的密文
     */
    public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        SecureRandom secureRandom = new SecureRandom(secret.getBytes());
        keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        // 获取公钥并写出
        byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
        publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
        writeFile(publicKeyFilename, publicKeyBytes);
        // 获取私钥并写出
        byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
        privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
        writeFile(privateKeyFilename, privateKeyBytes);
    }

    /**读文件
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:15:37
     * @param fileName
     * @return byte[]
     * @throws
     * @version 1.0
     */
    private static byte[] readFile(String fileName) throws Exception {
        return Files.readAllBytes(new File(fileName).toPath());
       
    }

    /**写文件
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:16:01
     * @param destPath
     * @param bytes
     * @return void
     * @throws
     * @version 1.0
     */
    private static void writeFile(String destPath, byte[] bytes) throws IOException {
        File dest = new File(destPath);
        if (!dest.exists()) {
            dest.createNewFile();
        }
        Files.write(dest.toPath(), bytes);
        
    }

    /**构造器私有化
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-04 13:16:29
     * @param
     * @return
     * @throws
     * @version 1.0
     */
    private RsaUtils() {

    }


}

生成私钥和公钥两个文件

/**
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 10:28
 */

public class RsaTest {
    private String publicFile = "D:\\Desktop\\rsa_key.pub";
    private String privateFile = "D:\\Desktop\\rsa_key";


    /**生成公钥和私钥
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 10:32:16
     * @throws Exception
     * @version 1.0
     */
    @Test
    public void generateKey() throws Exception{

        RsaUtils.generateKey(publicFile,privateFile,"Java开发实践",2048);

    }

}

私钥文件一定要保护好!!!

私钥文件一定要保护好!!!

私钥文件一定要保护好!!!

(重要的事情说三遍!!!)

##### 创建token有效载荷实体类和JWT工具类	
/**为了方便后期获取token中的用户信息,
 * 将token中载荷部分单独封装成一个对象
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/2 22:24
 */
public class Payload<T> implements Serializable {

    /**
     * token id
     */
    private String id;

    /**
     * 用户信息(用户名、角色...)
     */
    private T userInfo;

    /**
     * 令牌过期时间
     */
    private Date expiration;

    getter。。。
    setter。。。
}

JwtUtils

/**token工具类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/2 22:28
 */
public class JwtUtils {

    private static final String JWT_PAYLOAD_USER_KEY = "user";

    /**
     * 私钥加密token
     *
     * @param userInfo   载荷中的数据
     * @param privateKey 私钥
     * @param expire     过期时间,单位分钟
     * @return JWT
     */
    public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
                .setId(createJTI())
                .setExpiration(DateTime.now().plusMinutes(expire).toDate())
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
    }

    /**
     * 私钥加密token
     *
     * @param userInfo   载荷中的数据
     * @param privateKey 私钥
     * @param expire     过期时间,单位秒
     * @return JWT
     */
    public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
                .setId(createJTI())
                .setExpiration(DateTime.now().plusSeconds(expire).toDate())
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
    }

    /**
     * 公钥解析token
     *
     * @param token     用户请求中的token
     * @param publicKey 公钥
     * @return Jws<Claims>
     */
    private static Jws<Claims> parserToken(String token, PublicKey publicKey) throws ExpiredJwtException {
        return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
    }

    private static String createJTI() {
        return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
    }

    /**
     * 获取token中的用户信息
     *
     * @param token     用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) throws ExpiredJwtException {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setUserInfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
        claims.setExpiration(body.getExpiration());
        return claims;
    }

    /**
     * 获取token中的载荷信息
     *
     * @param token     用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setExpiration(body.getExpiration());
        return claims;
    }

    private JwtUtils() {

    }
}

写完common模块后,将其打包安装,后面的两个服务都需要引用。

创建认证服务模块authentication-service

认证服务模块的关键点在于自定义用户认证过滤器和用户校验过滤器,并将其加载到spring security的过滤器链中,替代掉默认的。

##### 修改pom.xml文件,添加相关依赖

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>pers.lbf</groupId>
        <artifactId>springboot-springSecurity-jwt-rsa</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <artifactId>authentication-service</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>authentication-service</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>pers.lbf</groupId>
            <artifactId>common</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

这个模块添加的依赖主要是springBoot整合spring security的相关依赖以及数据库相关的依赖,当然还有我们的common模块。

修改application.yml文件

这一步主要是设置数据库连接的信息以及公钥、私钥的位置信息

server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/security_authority?useSSL=false&serverTimezone=GMT
    username: root
    password: root1997
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    pers.lbf: debug
lbf:
  key:
    publicKeyPath: 你的公钥路径
    privateKeyPath: 你的私钥路径

配置解析公钥和私钥
**解析公钥和私钥的配置类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 10:42
 */
@ConfigurationProperties(prefix = "lbf.key")
@ConstructorBinding
public class AuthServerRsaKeyProperties {

    private String publicKeyPath;
    private String privateKeyPath;

    private PublicKey publicKey;
    private PrivateKey privateKey;


    /**加载文件当中的公钥、私钥
     * 被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,
     * 并且只会被服务器执行一次。PostConstruct在构造函数之后执行,
     * init()方法之前执行。
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 12:07:35
     * @throws Exception e
     * @version 1.0
     */
    @PostConstruct
    public void loadKey() throws Exception {
        publicKey = RsaUtils.getPublicKey(publicKeyPath);
        privateKey = RsaUtils.getPrivateKey(privateKeyPath);

    }

    public String getPublicKeyPath() {
        return publicKeyPath;
    }

    public void setPublicKeyPath(String publicKeyPath) {
        this.publicKeyPath = publicKeyPath;
    }

    public String getPrivateKeyPath() {
        return privateKeyPath;
    }

    public void setPrivateKeyPath(String privateKeyPath) {
        this.privateKeyPath = privateKeyPath;
    }

    public PublicKey getPublicKey() {
        return publicKey;
    }

    public void setPublicKey(PublicKey publicKey) {
        this.publicKey = publicKey;
    }

    public PrivateKey getPrivateKey() {
        return privateKey;
    }

    public void setPrivateKey(PrivateKey privateKey) {
        this.privateKey = privateKey;
    }
}
修改启动类,添加token加密解析的配置和mapper扫描
/**
 * @author Ferryman
 */
@SpringBootApplication
@MapperScan(value = "pers.lbf.ssjr.authenticationservice.dao")
@EnableConfigurationProperties(AuthServerRsaKeyProperties.class)
public class AuthenticationServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthenticationServiceApplication.class, args);
    }

}
创建用户登录对象UserLoginVO

我们将用户登录的请求参数封装到一个实体类当中,而不使用与数据库表对应的UserTO。

/**用户登录请求参数对象
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 16:16
 */
public class UserLoginVo implements Serializable {

    private String username;
    private String password;

    getter。。。
    settter。。。
}
创建用户凭证对象UserAuthVO

这个对象主要用于存储访问者认证成功后,其在token中的信息。这里我们是不存储密码等敏感数据的。

/**用户凭证对象
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 16:20
 */
public class UserAuthVO implements Serializable {

    private String username;
    private List<SimpleGrantedAuthority> authorities;

   getter。。。
   setter。。。
}
创建自定义认证过滤器
/**自定义认证过滤器
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 12:11
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    /**
     * 认证管理器
     */

    private AuthenticationManager authenticationManager;

    private AuthServerRsaKeyProperties prop;

    /**构造注入
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 12:17:54
     * @param authenticationManager spring security的认证管理器
     * @param prop 公钥 私钥 配置类
     * @version 1.0
     */
    public TokenLoginFilter(AuthenticationManager authenticationManager, AuthServerRsaKeyProperties prop) {
        this.authenticationManager = authenticationManager;
        this.prop = prop;

    }


    /**接收并解析用户凭证,并返回json数据
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 12:19:29
     * @param request req
     * @param response resp
     * @return Authentication
     * @version 1.0
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){

        //判断请求是否为POST,禁用GET请求提交数据
        if (!"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException(
                    "只支持POST请求方式");
        }


        //将json数据转换为java bean对象
        try {
            UserLoginVo user = new ObjectMapper().readValue(request.getInputStream(), UserLoginVo.class);

            if (user.getUsername()==null){
                user.setUsername("");
            }

            if (user.getPassword() == null) {
                user.setPassword("");
            }
            user.getUsername().trim();
//将用户信息交给spring security做认证操作
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            user.getUsername(),
                            user.getPassword()));
        }catch (Exception e) {

            throw new RuntimeException(e);
        }

    }

    /**这个方法会在验证成功时被调用
     *用户登录成功后,生成token,并且返回json数据给前端
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 13:00:23
     * @param request
     * @param response
     * @param chain
     * @param authResult
     * @version 1.0
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response, FilterChain chain, Authentication authResult) {
        //获取当前登录对象
        UserAuthVO user = new UserAuthVO();
        user.setUsername(authResult.getName());
        user.setAuthorities((List<SimpleGrantedAuthority>) authResult.getAuthorities());

        //使用jwt创建一个token,私钥加密
        String token = JwtUtils.generateTokenExpireInMinutes(user,prop.getPrivateKey(),15);

        //返回token
       response.addHeader("Authorization","Bearer"+token);

       //登录成功返回json数据提示
        try {
            //生成消息
            Map<String, Object> map = new HashMap<>();
            map.put("code",HttpServletResponse.SC_OK);
            map.put("msg","登录成功");
            //响应数据
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter writer = response.getWriter();
            writer.write(new ObjectMapper().writeValueAsString(map));
            writer.flush();
            writer.close();
        }catch (Exception e) {
            throw new RuntimeException(e);
        }
    }


}

到了这一步,你或许会开始觉得难以理解,这需要你稍微了解spring security的认证流程。可以阅读我之前的文章《Spring Security认证流程分析--练气后期》

创建自定义校验过滤器
/**自定义身份验证器
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 15:02
 */
public class TokenVerifyFilter extends BasicAuthenticationFilter {

    private AuthServerRsaKeyProperties prop;

    public TokenVerifyFilter(AuthenticationManager authenticationManager, AuthServerRsaKeyProperties prop) {
        super(authenticationManager);
        this.prop = prop;
    }

    /**过滤请求
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 15:07:27
     * @param request
     * @param response
     * @param chain
     * @version 1.0
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response, FilterChain chain) throws ServletException, IOException, AuthenticationException,ExpiredJwtException {

       //判断请求体的头中是否包含Authorization
       String authorization = request.getHeader("Authorization");
       //Authorization中是否包含Bearer,不包含直接返回
       if (authorization==null||!authorization.startsWith("Bearer")){
           chain.doFilter(request, response);
           return;
       }

       UsernamePasswordAuthenticationToken token;
       try {
           //解析jwt生成的token,获取权限
            token = getAuthentication(authorization);

       }catch (ExpiredJwtException e){
          // e.printStackTrace();
           chain.doFilter(request, response);
           return;
       }

        //获取后,将Authentication写入SecurityContextHolder中供后序使用
        SecurityContextHolder.getContext().setAuthentication(token);
        chain.doFilter(request, response);


    }



    /**对jwt生成的token进行解析
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 15:21:04
     * @param authorization auth
     * @return org.springframework.security.authentication.UsernamePasswordAuthenticationToken
     * @throws
     * @version 1.0
     */
    public UsernamePasswordAuthenticationToken getAuthentication(String authorization) throws ExpiredJwtException{

        if (authorization == null) {
            return null;
        }

        Payload<UserAuthVO> payload;

            //从token中获取有效载荷
        payload = JwtUtils.getInfoFromToken(authorization.replace("Bearer", ""), prop.getPublicKey(), UserAuthVO.class);



        //获取当前访问对象
        UserAuthVO userInfo = payload.getUserInfo();
        if (userInfo == null){
            return null;
        }

        //将当前访问对象及其权限封装称spring security可识别的token
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userInfo,null,userInfo.getAuthorities());
        return token;
    }
}
编写spring security的配置类

这一步主要是是完成对spring security的配置。唯一和单体版应用集成spring'security不同的是,在这一步需要加入我们自定义的用户认证和用户校验的过滤器,还有就是禁用session。

/**spring security配置类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 15:41
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userService;

    @Autowired
    private AuthServerRsaKeyProperties properties;

    @Bean
    public BCryptPasswordEncoder myPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }


    /**配置自定义过滤器
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 15:53:45
     * @param http
     * @version 1.0
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //禁用跨域保护,取代它的是jwt
        http.csrf().disable();

        //允许匿名访问的方法
        http.authorizeRequests().antMatchers("/login").anonymous();
                //其他需要鉴权
                //.anyRequest().authenticated();

        //添加认证过滤器
        http.addFilter(new TokenLoginFilter(authenticationManager(),properties));

        //添加验证过滤器
        http.addFilter(new TokenVerifyFilter(authenticationManager(),properties));


        //禁用session,前后端分离是无状态的
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);


    }



    /**配置密码加密策略
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 15:50:46
     * @param authenticationManagerBuilder
     * @version 1.0
     */
    @Override
    protected void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {

        authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(myPasswordEncoder());
    }

    @Override
    public void configure(WebSecurity webSecurity) throws Exception{
        //忽略静态资源
        webSecurity.ignoring().antMatchers("/assents/**","/login.html");
    }

}
添加对GrantedAuthority类型的自定义反序列化工具

因为我们的权限信息是加密存储于token中的,因此要对authorities进行序列化与反序列化,然后由于jackson并不支持对其进行反序列化,因此需要我们自己去做。

**
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 22:42
 */
public class CustomAuthorityDeserializer extends JsonDeserializer {

    @Override
    public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
        ObjectMapper mapper = (ObjectMapper) jp.getCodec();
        JsonNode jsonNode = mapper.readTree(jp);
        List<GrantedAuthority> grantedAuthorities = new LinkedList<>();

        Iterator<JsonNode> elements = jsonNode.elements();
        while (elements.hasNext()) {
            JsonNode next = elements.next();
            JsonNode authority = next.get("authority");
            grantedAuthorities.add(new SimpleGrantedAuthority(authority.asText()));
        }
        return grantedAuthorities;
    }

}

在UserAuthVO上标记

/**用户凭证对象
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 16:20
 */
public class UserAuthVO implements Serializable {

    @JsonDeserialize(using = CustomAuthorityDeserializer.class)
    public void setAuthorities(List<SimpleGrantedAuthority> authorities) {
        this.authorities = authorities;
    }

   //省略了其他无关的代码
}

实现UserDetailsService接口

实现loadUserByUsername方法,修改认证信息获取方式为:从数据库中获取权限信息。

/**
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/8/28 22:16
 */
@Service("userService")
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    private IUserDao userDao;
    @Autowired
    private IRoleDao roleDao;
    @Autowired
    private IPermissonDao permissonDao;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username == null){
            return null;
        }

        UserDO user = userDao.findByName(username);

        List<RoleDO> roleList = roleDao.findByUserId(user.getId());

        List<SimpleGrantedAuthority> list  = new ArrayList<> ();
        for (RoleDO roleDO : roleList) {
            List<PermissionDO> permissionListItems = permissonDao.findByRoleId(roleDO.getId());
            for (PermissionDO permissionDO : permissionListItems) {
                list.add(new SimpleGrantedAuthority(permissionDO.getPermissionUrl()));
            }
        }
        user.setAuthorityList(list);
        return user;
    }
}

提示:关于用户、角色、权限的数据库操作及其实体类到这里就省略了,不影响大家理解,当然,文末提供了完整的代码下载地址。

自定义401和403异常处理

Spring Security 中的异常主要分为两大类:一类是认证异常,另一类是授权相关的异常。并且,其抛出异常的地方是在过滤器链中,如果你使用@ControllerAdvice是没有办法处理的。

当然,像spring security这么优秀的框架,当然考虑到了这个问题。

spring security当中的HttpSecurity提供的exceptionHandling() 方法用来提供异常处理。该方法构造出 ExceptionHandlingConfigurer异常处理配置类。

然后该类呢有提供了两个接口用于我们自定义异常处理:

  • AuthenticationEntryPoint 该类用来统一处理 AuthenticationException异常(403异常)
  • AccessDeniedHandler 该类用来统一处理 AccessDeniedException异常(401异常)

MyAuthenticationEntryPoint.java

/**401异常处理
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 22:08
 */
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");

        response.setStatus(200);
        Map<String, Object> map = new HashMap<>();
        map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
        map.put("msg","令牌已过期请重新登录");

        ServletOutputStream out = response.getOutputStream();
        String s = new ObjectMapper().writeValueAsString(map);
        byte[] bytes = s.getBytes();
        out.write(bytes);
    }
}

MyAccessDeniedHandler.java

/**403异常处理
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 22:11
 */
public class MyAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        response.setStatus(200);
        Map<String, Object> map = new HashMap<>();
        map.put("code", HttpServletResponse.SC_FORBIDDEN);
        map.put("msg","未授权访问此资源,如有需要请联系管理员授权");
        ServletOutputStream out = response.getOutputStream();
        String s = new ObjectMapper().writeValueAsString(map);
        byte[] bytes = s.getBytes();
        out.write(bytes);
    }
}

将这两个类添加到spring security的配置当中

/**spring security配置类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/3 15:41
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userService;

    @Autowired
    private AuthServerRsaKeyProperties properties;

    @Bean
    public BCryptPasswordEncoder myPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }


    /**配置自定义过滤器
     * @author 赖柄沣 bingfengdev@aliyun.com
     * @date 2020-09-03 15:53:45
     * @param http
     * @version 1.0
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //其他代码。。。
       
        //添加自定义异常处理
        http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint());
        http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());

        //其他代码1


    }
    }

到这一步大家就可以运行启动类先进行测试一下。在本文当中就先将product-service模块也实现了再集中测试

创建子系统模块product-service

修改pom.xml文件

这一步和我们创建认证服务时相差无几。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>pers.lbf</groupId>
        <artifactId>springboot-springSecurity-jwt-rsa</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>product-service</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>product-service</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>pers.lbf</groupId>
            <artifactId>common</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

修改application.yml配置文件

这里主要是配置数据库信息和加入公钥的地址信息

server:
  port: 8082
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/security_authority?useSSL=false&serverTimezone=GMT
    username: root
    password: root1997
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    pers.lbf: debug
lbf:
  key:
    publicKeyPath: 你的公钥地址

创建读取公钥的配置类
/**读取公钥配置类
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/9/4 10:05
 */
@ConfigurationProperties(prefix = "lbf.key")
@ConstructorBinding
public class ProductRsaKeyProperties {

    private String publicKeyPath;
    private PublicKey publicKey;

    @PostConstruct
    public void loadKey() throws Exception {
        publicKey = RsaUtils.getPublicKey(publicKeyPath);
    }

    @Override
    public String toString() {
        return "ProductRsaKeyProperties{" +
                "pubKeyPath='" + publicKeyPath + '\'' +
                ", publicKey=" + publicKey +
                '}';
    }

    public String getPublicKeyPath() {
        return publicKeyPath;
    }

    public void setPublicKeyPath(String publicKeyPath) {
        this.publicKeyPath = publicKeyPath;
    }

    public PublicKey getPublicKey() {
        return publicKey;
    }

    public void setPublicKey(PublicKey publicKey) {
        this.publicKey = publicKey;
    }
}
修改启动类

这一步和创建认证服务器时一样,如要是加入公钥配置和mapper扫描

/**
 * @author Ferryman
 */
@SpringBootApplication
@MapperScan(basePackages = "pers.lbf.ssjr.productservice.dao")
@EnableConfigurationProperties(ProductRsaKeyProperties.class)
public class ProductServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductServiceApplication.class, args);
    }

}
复制

这一步主要是将UserAuthVo、自定义校验器、自定义异常处理器和自定义反序列化器从认证服务模块复制过来。(之所以不放入到公共模块common中是因为。不想直接在common模块中引入springBoot整合spring security的依赖)

创建子模块spring security配置类

这里也只需要在认证服务模块的配置上修改即可,去掉自定义认证过滤器的内容。资源模块只负责校验,不做认证。

创建一个测试接口
/**
 * @author 赖柄沣 bingfengdev@aliyun.com
 * @version 1.0
 * @date 2020/8/27 20:02
 */
@RestController
@RequestMapping("/product")
public class ProductController {


    @GetMapping("/get")
    @PreAuthorize("hasAuthority('product:get')")
    public String get() {
        return "产品信息接口调用成功!";
    }
}

第三步:启动项目,进行测试

登录(认证)操作

登录成功返回消息提示

并且可以在请求头中看到token

登陆失败提示"用户名或密码错误"

访问资源

携带令牌访问资源,且具备权限、令牌未过期

携带token访问资源。但是没有权限

未携带token访问(未登录、未经过认证)

携带过期令牌访问资源

写在最后

springBoot整合security实现权限管理与认证分布式版(前后端分离版)的的核心在于三个问题

  1. 禁用了session,用户信息保存在哪?

  2. 如何实现对访问者的认证,或者说是根据token去认证访问者?

  3. 如何实现对访问者的校验,或者说是根据token去校验访问者身份?

基本上我们解决了上面三个问题之后,springBoot整合spring security实现前后端分离(分布式)场景下的权限管理与认证问题我们就可以说是基本解决了。

代码以及sql脚本下载方式:微信搜索关注公众号【Java开发实践】,回复20200904即可得到下载链接。

posted @ 2020-09-04 18:18  __Ferryman  阅读(2911)  评论(2编辑  收藏  举报