springBoot整合spring security+JWT實現單點登入與許可權管理前後端分離--築基中期

Ferrayman發表於2020-09-04

寫在前面

在前一篇文章當中,我們介紹了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即可得到下載連結。

相關文章