SpringSecurity系列學習(四):基於JWT的認證


系列導航

SpringSecurity系列

SpringSecurityOauth2系列

基於JWT的認證

代碼參考

憋嗦話,上號!

咳咳,在上號之前,再聊兩句。。。

分布式認證

分布式認證,即我們常說的單點登錄,簡稱SSO,指的是在多應用系統的項目中,用戶只需要登錄一次,就可以訪問所有互相信任的應用系統。

但是首先,我們要明確,在分布式項目中,每台服務器都有各自獨立的session,而這些session之間是無法直接共享資源的,所以,session通常不能被作為單點登錄的技術方案。
最合理的單點登錄方案流程如下圖所示:

總結一下,單點登錄的實現分兩大環節:

  • 用戶認證:這一環節主要是客戶端向認證服務器發起認證請求,認證服務器給客戶端返回一個成功的令牌token,主要在認證服務器中完成,認證服務器只能有一個。
  • 身份校驗:這一環節是客戶端攜帶token去訪問其他服務器時,在其他服務器中要對token的真偽進行檢驗,主要在資源服務器中完成,資源服務器可以有很多個。

JWT

從分布式認證流程中,我們不難發現,這中間起最關鍵作用的就是token,token的安全與否,直接關系到系統的健壯性,這里我們選擇使用JWT來實現token的生成和校驗。

JWT,全稱JSON Web Token,官網地址https://jwt.io 是一款出色的分布式身份校驗方案。可以生成token,也可以解析檢驗token。

JWT生成的token由三部分組成:

  • 頭部(header):主要設置一些規范信息,簽名部分的編碼格式就在頭部中聲明(用的什么加密算法)。
  • 載荷(payload):token中存放有效信息的部分,比如用戶名,用戶角色,過期時間等,但是不要放密碼,會泄露!
  • 簽名(sign):將頭部與載荷分別采用base64編碼后,用“.”相連,再加入鹽,最后使用頭部聲明的編碼類型進行編碼,就得到了簽名。

其中JWT在荷載中已經聲明的字段:

  • lss:簽發者
  • exb:過期時間
  • sub:主題
  • aud:目標受眾
  • ...

還可以使用 Jwts.claim(key,value)添加字段

從JWT生成的token組成上來看,要想避免token被偽造,主要就得看簽名部分了,而簽名部分又有三部分組成,其中頭部和載荷的base64編碼,幾乎是透明的,毫無安全性可言,那么最終守護token安全的重擔就落在了加入的鹽上面了!

試想:如果生成token所用的鹽與解析token時加入的鹽是一樣的。豈不是類似於中國人民銀行把人民幣防偽技術公開了?大家可以用這個鹽來解析token,就能用來偽造token。

這時,我們就需要對鹽采用非對稱加密的方式進行加密,以達到生成token與校驗token方所用的鹽不一致的安全效果!

非對稱加密

基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱秘保存,公鑰可以下發給信任客戶端

  • 私鑰加密,持有公鑰才可以解密
  • 公鑰加密,持有私鑰才可解密

優點:安全,難以破解

缺點:算法比較耗時,為了安全,可以接受

縮寫:RSA。

對稱加密算法不能實現簽名,因此簽名只能非對稱算法

實戰

具體的代碼可以看githubspring-security-demo

引入依賴

我們首先開始實現JWT的創建,新建一個項目,引入依賴

Springboot:2.4.3

SpringSecurity:5.4.5

Mybatis-plus:3.4.3

mysql-connector:8.0.25

<?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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.cupricnitrate</groupId>
    <artifactId>spring-security-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>

        <jjwt.version>0.11.2</jjwt.version>

        <mybatis.plus.version>3.4.3</mybatis.plus.version>
        <mysql.version>8.0.25</mysql.version>
    </properties>

    <dependencies>

        <!--SpringSecurity-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--SpringBootWeb-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--參數驗證-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!--ORM-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis.plus.version}</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>

        <!--jwt相關依賴-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!--自定義配置屬性自動補全-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>

創建JWT

上文說到,JWT中有一個荷載(payload)部分,其包含了有效信息,比如用戶名,用戶角色,過期時間等。這里定義一個荷載實體類

/**
 * 荷載類
 * 為了方便后期獲取token中的用戶信息,將token中載荷部分單獨封裝成一個對象
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Data
public class Payload<T> {
    private String id;
    private T userInfo;
    private Date expiration;
    private Date issuedAt;
}

/**
 * 荷載中的數據
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ClaimInfo {
    /**
     * 用戶名
     */
    private String username;

    /**
     * 權限
     */
    private List<ClaimAuthority> authorities;

    @Data
    public static class ClaimAuthority implements GrantedAuthority{

        private String authority;

        @Override
        public String getAuthority() {
            return this.authority;
        }
    }
}

這里的ClaimAuthority類就是權限,其需要實現SpringSecurity的接口GrantedAuthority,SpringSecurity就是通過getAuthority()來讀取權限的

JWT的簽名部分,推薦使用非對稱加密的形式,非對稱加密的形式更為安全,當然在安全性要求沒有這么高的情況下,不使用非對稱加密也是可以的。

這里實現一下非對稱加密的工具類,便於生成JWT

package com.cupricnitrate.util;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
 * RSA工具類
 *
 * @author 硝酸銅
 * @date 2021/9/22
 */
public class RsaUtils {
    private static final int DEFAULT_KEY_SIZE = 2048;

    /**
     * 從文件中讀取公鑰
     *
     * @param filename 公鑰保存路徑
     * @return 公鑰對象
     * @throws Exception
     */
    public static PublicKey getPublicKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPublicKey(bytes);
    }

    /**
     * 從文件中讀取密鑰
     *
     * @param filename 私鑰保存路徑
     * @return 私鑰對象
     * @throws Exception
     */
    public static PrivateKey getPrivateKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPrivateKey(bytes);
    }

    /**
     * 獲取公鑰
     *
     * @param bytes 公鑰的字節形式
     * @return
     * @throws Exception
     */
    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);
    }

    /**
     * 獲取密鑰
     *
     * @param bytes 私鑰的字節形式
     * @return
     * @throws Exception
     */
    private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException,
            InvalidKeySpecException {
        bytes = Base64.getDecoder().decode(bytes);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
    }

    /**
     * 根據密文,生成rsa公鑰和私鑰,並寫入指定文件
     *
     * @param publicKeyFilename  公鑰文件絕對路徑,比如:xxx/xxx/rsa_key.pub
     * @param privateKeyFilename 私鑰文件絕對路徑,比如:xxx/xxx/rsa_key
     * @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);
    }
    public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
            secret) throws Exception {
        generateKey(publicKeyFilename,privateKeyFilename,secret,DEFAULT_KEY_SIZE);
    }

    private static byte[] readFile(String fileName) throws Exception {
        return Files.readAllBytes(new File(fileName).toPath());
    }

    private static void writeFile(String destPath, byte[] bytes) throws IOException {
        File dest = new File(destPath);
        File dir = dest.getParentFile();
        //判斷目錄是否存在,不在則新建
        if(!dir.exists()){
            dir.mkdirs();
        }
        //判斷文件是否存在,不在則新建
        if (!dest.exists()) {
            dest.createNewFile();
        }
        Files.write(dest.toPath(), bytes);
    }
}

有了這個工具類,就可以獲取公鑰,私鑰了

接下來,實現一個生成JWT的工具類

package com.cupricnitrate.util;

import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;

import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.*;

/**
 * 生成token以及校驗token相關方法
 *
 * @author 硝酸銅
 * @date 2021/9/22
 */
public class JwtUtils {

    private static final String JWT_PAYLOAD_USER_KEY = "user";

    // 用於HS512加密 簽名的key
    public static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);


    /**
     * 私鑰加密token
     *
     * @param claimInfo 載荷中的數據
     * @param key       key
     * @param expire    過期時間,單位ms
     * @return JWT
     */
    public static String generateTokenExpire(Object claimInfo,
                                             Key key,
                                             long expire,
                                             String id) {
        long now = System.currentTimeMillis();

        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY, claimInfo)
                .setId(id)
                .setExpiration(new Date(now + expire))
                .setIssuedAt(new Date(now))
                //RS256加密
                .signWith(key, SignatureAlgorithm.RS256)
                //如果使用HS512加密則使用這個
                //.signWith(key, SignatureAlgorithm.HS512).compact();
                .compact();
    }

    /**
     * 解析token
     *
     * @param token 用戶請求中的token
     * @param key   key
     * @return Jws<Claims>
     */
    public static Jws<Claims> parserToken(String token, Key key) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    }

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

    /**
     * 獲取token中的用戶信息
     *
     * @param token 用戶請求中的令牌
     * @param key   key
     * @return 用戶信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, Key key, Class<T> userType) {
        Jws<Claims> claimsJws = parserToken(token, key);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        ObjectMapper objectMapper = new ObjectMapper();
        claims.setUserInfo(objectMapper.convertValue(body.get(JWT_PAYLOAD_USER_KEY),userType));
        claims.setExpiration(body.getExpiration());
        claims.setIssuedAt(body.getIssuedAt());
        return claims;
    }

    /**
     * 獲取token中的載荷信息
     *
     * @param token 用戶請求中的令牌
     * @param key   key
     * @return 用戶信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, Key key) {
        Jws<Claims> claimsJws = parserToken(token, key);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setExpiration(body.getExpiration());
        claims.setIssuedAt(body.getIssuedAt());
        return claims;
    }

    /**
     * 驗證 token,忽略過期
     *
     * @param jwtToken token
     * @param key      key
     * @return boolean
     */
    public static boolean validateWithoutExpiration(String jwtToken, Key key) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
            return true;
        } catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            if (e instanceof ExpiredJwtException) {
                return true;
            }
        }
        return false;
    }

    /**
     * 驗證token
     *
     * @param jwtToken token
     * @param key      key
     * @return boolean
     */
    public static boolean validateToken(String jwtToken, Key key) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
            return true;
        } catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

寫一個main方法測試一下

public static void main(String[] args) throws Exception {
        //生成訪問令牌公鑰和私鑰文件
        String keyPublicFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key.pub";
        String keyPrivateFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key";
        //RsaUtils.generateKey(keyPublicFilePath, keyPrivateFilePath, "CupricNitrate Key Token");

        //生成刷新令牌公鑰和私鑰文件
        String refreshPublicFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub";
        String refreshPrivateFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key";
        //RsaUtils.generateKey(refreshPublicFilePath, refreshPrivateFilePath, "CupricNitrate Refresh Token");

        //模擬加密生成token
        PublicKey publicKey = RsaUtils.getPublicKey(keyPublicFilePath);
        PrivateKey privateKey = RsaUtils.getPrivateKey(keyPrivateFilePath);

        //權限設置
        List<ClaimInfo.ClaimAuthority> authorities = new ArrayList<>();
        ClaimInfo.ClaimAuthority authority = new ClaimInfo.ClaimAuthority();
        authority.setAuthority("ROLE_USER");
        authorities.add(authority);
        //荷載數據
        ClaimInfo claimInfo = ClaimInfo.builder()
                .username("user")
                .authorities(authorities)
                .build();

        //生成token
        String token = JwtUtils.generateTokenExpire(claimInfo, privateKey, 24 * 60 * 60 * 1000, createJTI());

        System.out.println("token: " + token);

        //模擬解密從token中獲取用戶信息
        ObjectMapper objectMapper = new ObjectMapper();
        //序列化時忽略值為null的屬性
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        Payload<User> payload = JwtUtils.getInfoFromToken(token,
                publicKey, User.class);
        User user1 = payload.getUserInfo();
        System.out.println("user: " + objectMapper.writeValueAsString(user1));
    }

控制台輸出:

token: eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoidXNlciIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJST0xFX1VTRVIifV19LCJqdGkiOiJPV1F6Tnpoa01HWXRNbVpoWWkwME9HUTRMV0ptTWpVdFl6TTVPRGczT1dVMk1XRTMiLCJleHAiOjE2MzI0NDk3NDgsImlhdCI6MTYzMjM2MzM0OH0.Isga_gn8JcskbFsIrzYWuzB-oLusSD7kUM6_gEr8TP5y7RrB0eS2l7YoNCox0xsdVBkf2ANn-zwqYvlqy3bDFCVgbNiNjUiZ3YiD0MJliR2J1Ci2sg0Tjt98EnxkzaS07rg9kfhrGhZ0vdb_EwMVmms1o6-a-gj1baOFsF3_ZmBL_rR4lDDPY5R86SUTJdVksZTBsnxv4bmqV15asnaJg8_f6mYuCH-dMeJ836G_EFgGu4XsqC_n5Fkm9fxkwzpzLdTlAXawgdiPeNhxwn_8bHcWhnP0m62L1pQj39mj15ghISAUs0EWlFP6DKNddCAQf3gDjfVPt5f-CSTv0JD04Q
user: {"username":"user","authorities":[{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}

使用這個token在jwt.io上解析:

這里可以看到JWT是可以通過被解析出來的,其非對稱加密只是將簽名進行加密,在驗簽的時候起到安全防護的作用。所以不要在JWT中存放敏感信息!

訪問令牌和刷新令牌

上文的main方法中,我生成了兩套公私鑰,訪問令牌公私鑰和刷新令牌公私鑰,為什么需要兩個令牌呢?我使用一個令牌不就可以了嗎?當然可以,但是使用兩個令牌更為安全!

令牌一旦簽發出去之后,就默認已經暴露出去了,因為不論我們保存在客戶端還是服務器中,別人想要獲取這個令牌,是很容易的。

所以,對於公開的令牌,我們采用的策略就是減少它的生命周期,比如說5分鍾有效,這樣別人拿到令牌之后,這個令牌可能已經過期了,令牌泄漏了也不會造成太大的影響。

還有就是在服務端做一些異常檢查,因為一個安全系統,是不能單純的依賴一個單一的手段的,比如還需要在服務端中檢查高頻訪問的IP這些東西,做一些限制。綜合來判斷是否有人盜取了用戶的信息,防止惡意用戶的攻擊。

  • 訪問令牌(直接開門的鑰匙):聲明周期要短,一般在幾分鍾到幾小時之間,防止令牌暴露之后,黑客為所欲為
  • 刷新令牌(不能直接開門,是用來生成新的訪問令牌用的):生命周期會長,應該在幾周到不超過一年
  • 上面兩種令牌使用不同的key簽發
  • 當訪問令牌過期之后,使用刷新令牌去生成新的訪問令牌。由於生成新的訪問令牌需要同時驗證刷新令牌和過期的訪問令牌,所以就降低了令牌暴露的危險。

創建JWT過濾器

SpringSecurityOauth2有內建的JwtFilter,並且支持得很好

但是SpringSecurity里面沒有單體應用的JwtFilter,需要我們自己實現

  • 認證成功就是把Authentication對象setAuthenticated(true),然后存到SecurityContext中。
  • 認證失敗就是清空SecurityContext然后交給下一個Filter處理

首先自定義一個屬性,便於token相關的配置

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * token配置屬性類
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Data
@Component
@ConfigurationProperties(prefix = TokenPropertities.PREFIX)
public class TokenProperties {
    public static final String PREFIX = "token";

    /**
     * Http報頭中令牌自定義標識,默認:Authorization
     */
    private String header = "Authorization";

    /**
     * Http報頭中令牌自定義標識中的開頭,默認:Bearer
     */
    private String prefix = "Bearer";

    /**
     * 訪問令牌相關屬性
     */
    private AccessToken access;

    /**
     * 刷新令牌相關屬性
     */
    private RefreshToken refresh;


    @Data
    public static class AccessToken{
        /**
         * 訪問令牌過期時間,單位ms,默認60s
         */
        private Long expireTime = 60 * 1000L;

        /**
         * 訪問令牌私鑰文件訪問路徑,比如/user/auth_key/rsa_key
         */
        private String privateKey;

        /**
         * 訪問令牌公鑰文件訪問路徑,比如/user/auth_key/rsa_key.pub
         */
        private String publicKey;

    }

    @Data
    public static class RefreshToken{
        /**
         * 刷新令牌過期時間,單位ms,默認30天
         */
        private Long expireTime = 30 * 24 * 60 * 60 * 1000L;

        /**
         * 訪問令牌私鑰文件訪問路徑,比如/user/auth_key/rsa_key
         */
        private String privateKey;

        /**
         * 訪問令牌公鑰文件訪問路徑,比如/user/auth_key/rsa_key.pub
         */
        private String publicKey;
    }
}

然后在application.yaml中將其配置好

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    #mysql驅動8.x版本使用com.mysql.cj.jdbc.Driver
    #5.x使用com.mysql.jdbc.Driver
    driver-class-name: com.mysql.cj.jdbc.Driver
    #數據庫地址
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
    #數據庫賬號
    username: root
    #數據庫密碼
    password: root
    #hikari連接池
    hikari:
      #2*cpu
      maximum-pool-size: 16
      #cpu
      minimum-idle: 8
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true

token:
  access:
    public-key: /Users/xiaoshengpeng/auth_key/key/rsa_key.pub
    private-key: /Users/xiaoshengpeng/auth_key/key/rsa_key
  refresh:
    public-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub
    private-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key

接下來我們來實現簽發token的步驟。

一般我們我們將簽發token的步驟寫在登錄中

還記得我們怎么自定義認證邏輯的嗎?

創建一個Jwt認證過濾器,使其繼承UsernamePasswordAuthenticationFilter

import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.http.req.LoginReqDto;
import com.cupricnitrate.http.resp.LoginRespDto;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Jwt認證過濾器
 * @author 硝酸銅
 * @date 2021/9/22
 */
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    private final TokenProperties tokenProperties;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager,TokenProperties tokenProperties) {
        this.authenticationManager = authenticationManager;
        this.tokenProperties = tokenProperties;
        // 瀏覽器訪問 /authorize/login 會通過 JWTAuthenticationFilter
        setFilterProcessesUrl("/authorize/login");
    }

    /**
     * json格式:
     *
     * {
     *     "username": "user",
     *     "password": "12345678"
     * }
     *
     * @param request 請求體
     * @param response 返回體
     * @return Authentication
     * @throws AuthenticationException 認證異常
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        InputStream is = null;
        LoginReqDto req = null;
        try {
            //從Body中讀取參數
            is = request.getInputStream();
            //使用jackson解析json
            ObjectMapper objectMapper = new ObjectMapper();
            req = objectMapper.readValue(is,LoginReqDto.class);
        } catch (IOException e) {
            e.printStackTrace();
            throw new BadCredentialsException("json格式錯誤,沒有找到用戶名或密碼");
        }

        //認證,同父類,生成一個沒有被完全初始化的Authentication
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
        this.setDetails(request, authRequest);
        return authenticationManager.authenticate(authRequest);
    }


    /**
     * 認證成功邏輯
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //令牌私鑰
        PrivateKey accessPrivateKey = null;
        PrivateKey refreshPrivateKey = null;
        try {
            accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
            refreshPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getRefresh().getPrivateKey());
        } catch (Exception e) {
            e.printStackTrace();
        }

        //創建荷載信息
        List<ClaimInfo.ClaimAuthority> authorities = authResult.getAuthorities().stream().map(a -> {
            ClaimInfo.ClaimAuthority claimAuthority = new ClaimInfo.ClaimAuthority();
            claimAuthority.setAuthority(a.getAuthority());
            return claimAuthority;
        }).collect(Collectors.toList());

        ClaimInfo claim = ClaimInfo.builder().username(authResult.getName()).authorities(authorities).build();
        //簽發token,使用私鑰進行簽發
        LoginRespDto respDto = new LoginRespDto(
                JwtUtils.generateTokenExpire(claim, accessPrivateKey,tokenProperties.getAccess().getExpireTime(), JwtUtils.createJTI()),
                JwtUtils.generateTokenExpire(claim, refreshPrivateKey,tokenProperties.getRefresh().getExpireTime(),JwtUtils.createJTI()));

        try {
            //登錄成功時,返回json格式進行提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpServletResponse.SC_OK);
            map.put("message", "登陸成功!");
            map.put("token",respDto);
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }

    /**
     * 登錄失敗邏輯
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        try {
            //登錄成功時,返回json格式進行提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpServletResponse.SC_FORBIDDEN);
            map.put("message", "登陸失敗!");
            map.put("reason",failed.getMessage());
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }
}

具體邏輯可以看注釋,很清晰了

完成了認證邏輯,還需要實現一個驗證token的過濾器,客戶端在登陸后,請求接口的時候,在Http Header中帶上Authorization:Bearer XXX(xxx是JWT)即可通過認證

import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author 硝酸銅
 * @date 2021/9/22
 */
public class JwtVerifyFilter extends BasicAuthenticationFilter {

    private TokenProperties tokenProperties;

    public JwtVerifyFilter(AuthenticationManager authenticationManager, TokenProperties tokenProperties) {
        super(authenticationManager);
        this.tokenProperties = tokenProperties;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        if (checkJwtToken(request)) {
            try {
                //獲取權限失敗,會拋出異常
                UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
                //獲取后,將Authentication寫入SecurityContextHolder中供后序使用
                SecurityContextHolder.getContext().setAuthentication(authentication);
                chain.doFilter(request, response);
            } catch (Exception e) {
                responseJson(response);
                e.printStackTrace();
            }
        } else {
            //token不在請求頭中,則說明是匿名用戶訪問
            List<GrantedAuthority> list = new ArrayList<>();
            GrantedAuthority grantedAuthority = () -> "ROLE_ANONYMOUS";
            list.add(grantedAuthority);
            AnonymousAuthenticationToken authentication = new AnonymousAuthenticationToken("token-anonymousUser","anonymousUser", list);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        }
    }


    /**
     * 檢查JWT Token 是否在HTTP 報頭中
     *
     * @param request HTTP請求
     * @return boolean
     */
    private boolean checkJwtToken(HttpServletRequest request) {
        String header = request.getHeader(tokenProperties.getHeader());
        return header != null && header.startsWith(tokenProperties.getPrefix());
    }

    /**
     * 未登錄提示
     *
     * @param response
     */
    private void responseJson(HttpServletResponse response) {
        try {
            //未登錄提示
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            PrintWriter out = response.getWriter();
            Map<String, Object> map = new HashMap<String, Object>();
            map.put("code", HttpServletResponse.SC_FORBIDDEN);
            map.put("message", "未登錄或登錄過期,請進行登錄!");
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }

    /**
     * 通過token,獲取用戶信息
     *
     * @param request
     * @return
     */
    @SneakyThrows
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        //讀取請求頭中的Authorization的值
        String token = request.getHeader("Authorization");
        if (token != null) {
            //Authorization 中JWT傳參,默認格式 Authorization:Bearer XXX
            token = token.replaceFirst(tokenProperties.getPrefix(), "");
            //通過token解析出載荷信息,使用公鑰進行解析
            Payload<ClaimInfo> payload = JwtUtils.getInfoFromToken(token, RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey()), ClaimInfo.class);
            ClaimInfo claimInfo = payload.getUserInfo();
            //不為null,返回一個完全初始化的Authentication
            if (claimInfo != null) {
                return new UsernamePasswordAuthenticationToken(claimInfo.getUsername(), null, claimInfo.getAuthorities());
            }
            return null;

        }
        return null;
    }
}

最后,將這兩個安全過濾器設置到SpringSecurity的安全配置中即可

/**
 * @author 硝酸銅
 * @date 2021/9/22
 */
@EnableWebSecurity(debug = true)
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private TokenProperties tokenProperties;
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //禁用生成默認的登陸頁面
                .formLogin(AbstractHttpConfigurer::disable)
                //關閉httpBasic,采用自定義過濾器
                .httpBasic(AbstractHttpConfigurer::disable)
                //前后端分離架構不需要csrf保護,這里關閉
                .csrf(AbstractHttpConfigurer::disable)
                //禁用生成默認的注銷頁面
                .logout(AbstractHttpConfigurer::disable)
                .authorizeRequests(req -> req
                        //允許訪問authorize url下的所有接口
                        .antMatchers("/authorize/**").permitAll()
                        .anyRequest().authenticated()
                )
                //添加我們自定義的過濾器,替代UsernamePasswordAuthenticationFilter
                .addFilterAt(new JwtAuthenticationFilter(authenticationManager(),tokenProperties), UsernamePasswordAuthenticationFilter.class)
                //添加token檢驗過濾器
                .addFilter(new JwtVerifyFilter(authenticationManager(),tokenProperties))
                //前后端分離是無狀態的,不用session了,直接禁用。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
		...
}

刷新令牌接口

登陸是獲取令牌的接口,那么訪問令牌過期了,需要使用刷新令牌去刷新令牌

我們接下來實現一下刷新令牌的接口

/**
 * @author 硝酸銅
 * @date 2021/9/22
 */
@RestController
@RequestMapping("/authorize")
public class AuthorizeController {
  ...
    /**
     * 刷新訪問令牌
     * @param req 請求體
     * @param authorization 訪問令牌
     * @return LoginRespDto
     */
    @PostMapping(value = "/refreshToken")
    public LoginRespDto refreshToken(@Validated @RequestBody RefreshTokenReqDto req, @RequestHeader(name = "Authorization") String authorization){
        return tokenService.refreshToken(authorization.replaceFirst("Bearer ", ""),req.getRefreshToken());
    }
  ...
}


/**
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Data
public class RefreshTokenReqDto implements Serializable {
    private static final long serialVersionUID = 8410311036049755024L;

    /**
     * 刷新令牌
     */
    @NotBlank
    private String refreshToken;
}
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Optional;

/**
 * @author 硝酸銅
 * @date 2021/9/22
 */
@Service
public class TokenService {

    @Resource
    private TokenProperties tokenProperties;

    /**
     * 使用刷新token創建訪問token
     * @param token 訪問token
     * @param refreshToken 刷新token
     * @return 訪問token
     */
    public LoginRespDto refreshToken(String token, String refreshToken){
        LoginRespDto resp = new LoginRespDto();
        //獲取公鑰和私鑰
        PublicKey accessPublicKey = null;
        PrivateKey accessPrivateKey = null;
        PublicKey refreshPublicKey = null;
        PrivateKey refreshPrivateKey = null;
        try {
            //訪問令牌公鑰
            accessPublicKey = RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey());
            //訪問令牌私鑰
            accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
            //刷新令牌公鑰
            refreshPublicKey = RsaUtils.getPublicKey(tokenProperties.getRefresh().getPublicKey());
        } catch (Exception e) {
            e.printStackTrace();
        }

        //解析刷新令牌並生成新的訪問令牌
        if(JwtUtils.validateWithoutExpiration(token,accessPublicKey) &&
                JwtUtils.validateToken(refreshToken,refreshPublicKey)){
            PrivateKey key = accessPrivateKey;

            //生成新的訪問令牌
            String accessToken = Optional.ofNullable(JwtUtils.parserToken(refreshToken, refreshPublicKey))
                    .map(claims ->
                            JwtUtils.generateTokenExpire(claims.getBody(),
                                    key,
                                    tokenProperties.getAccess().getExpireTime(),
                                    JwtUtils.createJTI()))
                    .orElseThrow(() -> new AccessDeniedException("訪問被拒絕"));

            resp.setAccessToken(accessToken);
        }
        return resp;
    }
}

調用接口

我們來驗證一下,先進行登陸:

這個時候訪問接口是可以通過認證的

等待一定時間,訪問令牌過期

這個時候,調用刷新令牌接口,生成新的訪問令牌

使用新的訪問令牌,又能通過認證了

到此為止,關於認證的事情我們就做完了

接下來我們來學習一下授權的事情


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM