一文瞭解什麼是JWT

起源

需要瞭解一門技術,首先從為什麼產生開始說起是最好的。JWT 主要用於用戶登錄鑒權,所以我們從最傳統的 session 認證開始說起。

session認證

眾所周知,http 協議本身是無狀態的協議,那就意味著當有用戶向系統使用賬戶名稱和密碼進行用戶認證之後,下一次請求還要再一次用戶認證才行。因為我們不能通過 http 協議知道是哪個用戶發出的請求,所以如果要知道是哪個用戶發出的請求,那就需要在服務器保存一份用戶信息(保存至 session ),然後在認證成功後返回 cookie 值傳遞給瀏覽器,那麼用戶在下一次請求時就可以帶上 cookie 值,服務器就可以識別是哪個用戶發送的請求,是否已認證,是否登錄過期等等。這就是傳統的 session 認證方式。

session 認證的缺點其實很明顯,由於 session 是保存在服務器裡,所以如果分佈式部署應用的話,會出現session不能共享的問題,很難擴展。於是乎為瞭解決 session 共享的問題,又引入瞭 redis,接著往下看。

token認證

這種方式跟 session 的方式流程差不多,不同的地方在於保存的是一個 token 值到 redis,token 一般是一串隨機的字符(比如UUID),value 一般是用戶ID,並且設置一個過期時間。每次請求服務的時候帶上 token 在請求頭,後端接收到token 則根據 token 查一下 redis 是否存在,如果存在則表示用戶已認證,如果 token 不存在則跳到登錄界面讓用戶重新登錄,登錄成功後返回一個 token 值給客戶端。

優點是多臺服務器都是使用 redis 來存取 token,不存在不共享的問題,所以容易擴展。缺點是每次請求都需要查一下redis,會造成 redis 的壓力,還有增加瞭請求的耗時,每個已登錄的用戶都要保存一個 token 在 redis,也會消耗 redis 的存儲空間。

有沒有更好的方式呢?接著往下看。

什麼是JWT

JWT (全稱:Json Web Token)是一個開放標準(RFC 7519),它定義瞭一種緊湊的、自包含的方式,用於作為 JSON 對象在各方之間安全地傳輸信息。該信息可以被驗證和信任,因為它是數字簽名的。

上面說法比較文縐縐,簡單點說就是一種認證機制,讓後臺知道該請求是來自於受信的客戶端。

首先我們先看一個流程圖:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7UDQ9SEm-1653810812356)(什麼是JWT.assets/v2-bd0aeaf5ba1bad5ab1edff601e9b7a78_720w.jpg)]

流程描述一下:

  • 用戶使用賬號、密碼登錄應用,登錄的請求發送到 Authentication Server。
  • Authentication Server 進行用戶驗證,然後創建 JWT 字符串返回給客戶端。
  • 客戶端請求接口時,在請求頭帶上 JWT。
  • Application Server 驗證 JWT 合法性,如果合法則繼續調用應用接口返回結果。

可以看出與token方式有一些不同的地方,就是不需要依賴 redis,用戶信息存儲在客戶端。所以關鍵在於生成 JWT 和解析 JWT 這兩個地方。

JWT的數據結構

JWT 一般是這樣一個字符串,分為三個部分,以 “.” 隔開:

xxxxx.yyyyy.zzzzz

Header

JWT 第一部分是頭部分,它是一個描述 JWT 元數據的 Json 對象,通常如下所示。

{
    "alg": "HS256",
    "typ": "JWT"
}

alg 屬性表示簽名使用的算法,默認為 HMAC SHA256(寫為HS256),typ 屬性表示令牌的類型,JWT 令牌統一寫為JWT。

最後,使用 Base64 URL 算法將上述 JSON 對象轉換為字符串保存。

Payload

JWT 第二部分是 Payload,也是一個 Json 對象,除瞭包含需要傳遞的數據,還有七個默認的字段供選擇。

  • iss (issuer):簽發人/發行人
  • sub (subject):主題
  • aud (audience):用戶
  • exp (expiration time):過期時間
  • nbf (Not Before):生效時間,在此之前是無效的
  • iat (Issued At):簽發時間
  • jti (JWT ID):用於標識該 JWT

如果自定義字段,可以這樣定義:

{
    //默認字段
    "sub":"主題123",
    //自定義字段
    "name":"java技術愛好者",
    "isAdmin":"true",
    "loginTime":"2021-12-05 12:00:03"
}

需要註意的是,默認情況下 JWT 是未加密的,任何人都可以解讀其內容,因此一些敏感信息不要存放於此,以防信息泄露。

JSON 對象也使用 Base64 URL 算法轉換為字符串後保存,是可以反向反編碼回原樣的,這也是為什麼不要在 JWT 中放敏感數據的原因。

Signature

header (base64URL 加密後的)
payload (base64URL 加密後的)
secret

JWT 第三部分是簽名。是這樣生成的,首先需要指定一個 secret,該 secret 僅僅保存在服務器中,保證不能讓其他用戶知道。這個部分需要 base64URL 加密後的 header 和 base64URL 加密後的 payload 使用 . 連接組成的字符串,然後通過header 中聲明的加密算法 進行加鹽secret組合加密,然後就得出一個簽名哈希,也就是Signature,且無法反向解密。

那麼 Application Server 如何進行驗證呢?可以利用 JWT 前兩段,用同一套哈希算法和同一個 secret 計算一個簽名值,然後把計算出來的簽名值和收到的 JWT 第三段比較,如果相同則認證通過。

JWT的優點

  • json格式的通用性,所以JWT可以跨語言支持,比如Java、JavaScript、PHP、Node等等。
  • 可以利用Payload存儲一些非敏感的信息。
  • 便於傳輸,JWT結構簡單,字節占用小。
  • 不需要在服務端保存會話信息,易於應用的擴展。

怎麼使用JWT

首先引入Maven依賴。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

創建工具類,用於創建(生成) jwt 字符串和解析 jwt。

@Component
public class JwtUtil {
    @Value("${jwt.secretKey}")
    private String secretKey;
    public String createJWT(String id, String subject, long ttlMillis, Map<String, Object> map) throws Exception {
        JwtBuilder builder = Jwts.builder()
                .setId(id)
                .setSubject(subject) // 發行者
                .setIssuedAt(new Date()) // 發行時間
                .signWith(SignatureAlgorithm.HS256, secretKey) // 簽名類型 與 密鑰
                .compressWith(CompressionCodecs.DEFLATE);// 對載荷進行壓縮
        if (!CollectionUtils.isEmpty(map)) {
            builder.setClaims(map);
        }
        if (ttlMillis > 0) {
            builder.setExpiration(new Date(System.currentTimeMillis() + ttlMillis));
        }
        return builder.compact();
    }
    public Claims parseJWT(String jwtString) {
        return Jwts.parser().setSigningKey(secretKey)
                .parseClaimsJws(jwtString)
                .getBody();
    }
}

接著在application.yml配置文件配置jwt.secretKey

## 用戶生成jwt字符串的secretKey
jwt:
  secretKey: ak47

接著創建一個響應體。

public class BaseResponse {
    private String code;
    private String msg;
    public static BaseResponse success() {
        return new BaseResponse("0", "成功");
    }
    public static BaseResponse fail() {
        return new BaseResponse("1", "失敗");
    }
    //構造器、getter、setter方法
}
public class JwtResponse extends BaseResponse {
    private String jwtData;
    public static JwtResponse success(String jwtData) {
        BaseResponse success = BaseResponse.success();
        return new JwtResponse(success.getCode(), success.getMsg(), jwtData);
    }
    public static JwtResponse fail(String jwtData) {
        BaseResponse fail = BaseResponse.fail();
        return new JwtResponse(fail.getCode(), fail.getMsg(), jwtData);
    }
    //構造器、getter、setter方法
}

接著創建一個UserController:

@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private UserService userService;

    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public JwtResponse login(@RequestParam(name = "userName") String userName,
                             @RequestParam(name = "passWord") String passWord){
        String jwt = "";
        try {
            jwt = userService.login(userName, passWord);
            return JwtResponse.success(jwt);
        } catch (Exception e) {
            e.printStackTrace();
            return JwtResponse.fail(jwt);
        }
    }
}

還有UserService:

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private JwtUtil jwtUtil;

    @Resource
    private UserMapper userMapper;

    @Override
    public String login(String userName, String passWord) throws Exception {
        //登錄驗證
        User user = userMapper.findByUserNameAndPassword(userName, passWord);
        if (user == null) {
            return null;
        }
        //如果能查出,則表示賬號密碼正確,生成jwt返回
        String uuid = UUID.randomUUID().toString().replace("-", "");
        HashMap<String, Object> map = new HashMap<>();
        map.put("name", user.getName());
        map.put("age", user.getAge());
        return jwtUtil.createJWT(uuid, "login subject", 0L, map);
    }
}

還有UserMapper.xml:

@Mapper
public interface UserMapper {
    User findByUserNameAndPassword(@Param("userName") String userName, @Param("passWord") String passWord);

}

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.yehongzhi.jwtdemo.mapper.UserMapper">
    <select id="findByUserNameAndPassword" resultType="io.github.yehongzhi.jwtdemo.model.User">
        select * from user where user_name = #{userName} and pass_word = #{passWord}
    </select>
</mapper>

user 表結構如下:

啟動項目,然後用 postman 請求 login 接口。

返回的 jwt 字符串如下:

eyJhbGciOiJIUzI1NiIsInppcCI6IkRFRiJ9.eNqqVspLzE1VslJ6OnHFsxnzX67coKSjlJgOFDEzqAUAAAD__w.qib2DrjRKcFnY77Cuh_b1zSzXfISOpCA-g8PlAZCWoU

接著我們寫一個接口接收這個 jwt,並做驗證。

@RestController
@RequestMapping("/jwt")
public class TestController {
    @Resource
    private JwtUtil jwtUtil;
    @RequestMapping("/test")
    public Map<String, Object> test(@RequestParam("jwt") String jwt) {
        //這個步驟可以使用自定義註解+AOP編程做解析jwt的邏輯,這裡為瞭簡便就直接寫在controller裡
        Claims claims = jwtUtil.parseJWT(jwt);
        String name = claims.get("name", String.class);
        String age = claims.get("age", String.class);
        HashMap<String, Object> map = new HashMap<>();
        map.put("name", name);
        map.put("age", age);
        map.put("code", "0");
        map.put("msg", "請求成功");
        return map;
    }
}

像這樣能正常解析成功的話,就表示該用戶登錄未過期,並且已認證成功,所以可以正常調用服務。那麼有人會問瞭,這個 jwt 字符串能不能被偽造呢?

除非你知道 secretKey,否則是不能偽造的。比如客戶端隨便猜一個 secretKey 的值,然後偽造一個jwt:

eyJhbGciOiJIUzI1NiIsInppcCI6IkRFRiJ9.eNqqVspLzE1VslJ6OnHFsxnzX67coKSjlJgOFDEzqAUAAAD__w.bHr9p3-t2qR4R50vifRVyaYYImm2viZqiTlDdZHmF5Y

然後傳進去解析,會報以下錯誤:

還記得原理吧,是根據前面兩部分(Header、Payload)加上 secretKey 使用 Header 指定的哈希算法計算出第三部分(Signature),所以可以看出最關鍵就是 secretKey。secretKey隻有服務端自己知道,所以客戶端不知道 secretKey 的值是偽造不瞭jwt字符串的。

總結

最後講講 JWT 的缺點,因為任何技術都不是完美的,所以我們得用辯證思維去看待任何一項技術。

安全性沒法保證,所以 jwt 裡不能存儲敏感數據。因為 jwt 的 payload 並沒有加密,隻是用 Base64 編碼而已。無法中途廢棄。因為一旦簽發瞭一個 jwt,在到期之前始終都是有效的,如果用戶信息發生更新瞭,隻能等舊的 jwt 過期後重新簽發新的 jwt。續簽問題。當簽發的 jwt 保存在客戶端,客戶端一直在操作頁面,按道理應該一直為客戶端續長有效時間,否則當 jwt有效期到瞭就會導致用戶需要重新登錄。那麼怎麼為 jwt 續簽呢?最簡單粗暴就是每次簽發新的 jwt,但是由於過於暴力,會影響性能。如果要優雅一點,又要引入 Redis 解決,但是這又把無狀態的 jw t硬生生變成瞭有狀態的,違背瞭初衷。

所以印證瞭那句話,沒有最好的技術,隻有適合的技術。

到此這篇關於一文瞭解什麼是JWT的文章就介紹到這瞭,更多相關JWT內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: