手把手帶你使用JWT實現單點登入

潘志的研发笔记發表於2024-07-03

JWT(英文全名:JSON Web Token)是目前最流行的跨域身份驗證解決方案之一,今天我們一起來揭開它神秘的面紗!

一、故事起源

說起 JWT,我們先來談一談基於傳統session認證的方案以及瓶頸。

傳統session互動流程,如下圖:

當瀏覽器向伺服器傳送登入請求時,驗證透過之後,會將使用者資訊存入seesion中,然後伺服器會生成一個sessionId放入cookie中,隨後返回給瀏覽器。

當瀏覽器再次傳送請求時,會在請求頭部的cookie中放入sessionId,將請求資料一併傳送給伺服器。

伺服器就可以再次從seesion獲取使用者資訊,整個流程完畢!

通常在服務端會設定seesion的時長,例如 30 分鐘沒有活動,會將已經存放的使用者資訊從seesion中移除。

session.setMaxInactiveInterval(30 * 60);//30分鐘沒活動,自動移除

同時,在服務端也可以透過seesion來判斷當前使用者是否已經登入,如果為空表示沒有登入,直接跳轉到登入頁面;如果不為空,可以從session中獲取使用者資訊即可進行後續操作。

在單體應用中,這樣的互動方式,是沒啥問題的。

但是,假如應用伺服器的請求量變得很大,而單臺伺服器能支撐的請求量是有限的,這個時候就容易出現請求變慢或者OOM

解決的辦法,要麼給單臺伺服器增加配置,要麼增加新的伺服器,透過負載均衡來滿足業務的需求。

如果是給單臺伺服器增加配置,請求量繼續變大,依然無法支撐業務處理。

顯而易見,增加新的伺服器,可以實現無限的水平擴充套件。

但是增加新的伺服器之後,不同的伺服器之間的sessionId是不一樣的,可能在A伺服器上已經登入成功了,能從伺服器的session中獲取使用者資訊,但是在B伺服器上卻查不到session資訊,此時肯定無比的尷尬,只好退出來繼續登入,結果A伺服器中的session因為超時失效,登入之後又被強制退出來要求重新登入,想想都挺尷尬~~

面對這種情況,幾位大佬於是合起來商議,想出了一個token方案。

將各個應用程式與記憶體資料庫redis相連,對登入成功的使用者資訊進行一定的演算法加密,生成的ID被稱為token,將token還有使用者的資訊存入redis;等使用者再次發起請求的時候,將token還有請求資料一併傳送給伺服器,服務端驗證token是否存在redis中,如果存在,表示驗證透過,如果不存在,告訴瀏覽器跳轉到登入頁面,流程結束。

token方案保證了服務的無狀態,所有的資訊都是存在分散式快取中。基於分散式儲存,這樣可以水平擴充套件來支援高併發。

當然,現在springboot還提供了session共享方案,類似token方案將session存入到redis中,在叢集環境下實現一次登入之後,每個伺服器都可以獲取到使用者資訊。

二、JWT是什麼

上文中,我們談到的session還有token的方案,在叢集環境下,他們都是靠第三方快取資料庫redis來實現資料的共享。

那有沒有一種方案,不用快取資料庫redis來實現使用者資訊的共享,以達到一次登入,處處可見的效果呢?

答案肯定是有的,就是我們今天要介紹的JWT

JWT全稱JSON Web Token,實現過程簡單的說就是使用者登入成功之後,將使用者的資訊進行加密,然後生成一個token返回給客戶端,與傳統的session互動沒太大區別。

互動流程如下:

唯一的不同點就是token存放了使用者的基本資訊,更直觀一點就是將原本放入redis中的使用者資料,放入到token中去了!

這樣一來,客戶端、服務端都可以從token中獲取使用者的基本資訊,既然客戶端可以獲取,肯定是不能存放敏感資訊的,因為瀏覽器可以直接從token獲取使用者資訊。

JWT具體長什麼樣呢?

JWT是由三段資訊構成的,將這三段資訊文字用.連結一起就構成了JWT字串。就像這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  • 第一部分:我們稱它為頭部(header),用於存放token型別和加密協議,一般都是固定的;
  • 第二部分:我們稱其為載荷(payload),使用者資料就存放在裡面;
  • 第三部分:是簽證(signature),主要用於服務端的驗證;
1、header

JWT的頭部承載兩部分資訊:

  • 宣告型別,這裡是JWT;
  • 宣告加密的演算法,通常直接使用 HMAC SHA256;

完整的頭部就像下面這樣的JSON:

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

使用base64加密,構成了第一部分。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
2、playload

載荷就是存放有效資訊的地方,這些有效資訊包含三個部分:

  • 標準中註冊的宣告;
  • 公共的宣告;
  • 私有的宣告;

其中,標準中註冊的宣告 (建議但不強制使用)包括如下幾個部分

  • iss: jwt簽發者;
  • sub: jwt所面向的使用者;
  • aud: 接收jwt的一方;
  • exp: jwt的過期時間,這個過期時間必須要大於簽發時間;
  • nbf: 定義在什麼時間之前,該jwt都是不可用的;
  • iat: jwt的簽發時間;
  • jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊;

公共的宣告部分
公共的宣告可以新增任何的資訊,一般新增使用者的相關資訊或其他業務需要的必要資訊,但不建議新增敏感資訊,因為該部分在客戶端可解密。

私有的宣告部分
私有宣告是提供者和消費者所共同定義的宣告,一般不建議存放敏感資訊,因為base64是對稱解密的,意味著該部分資訊可以歸類為明文資訊。

定義一個payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然後將其進行base64加密,得到Jwt的第二部分:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
3、signature

jwt的第三部分是一個簽證資訊,這個簽證資訊由三部分組成:

  • header (base64後的);
  • payload (base64後的);
  • secret (金鑰);

這個部分需要base64加密後的headerbase64加密後的payload使用.連線組成的字串,然後透過header中宣告的加密方式進行加鹽secret組合加密,然後就構成了jwt的第三部分。

//javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, '金鑰');

加密之後,得到signature簽名資訊。

TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

將這三部分用.連線成一個完整的字串,就構成了最終的jwt:

//jwt最終格式
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

這個只是透過javascript實現的一個演示,JWT的簽發和金鑰的儲存都是在服務端來完成。

secret用來進行jwt的簽發和jwt的驗證,所以,在任何場景都不應該流露出去

三、實戰

介紹了這麼多,怎麼實現呢?廢話不多說,下面我們直接開擼!

  • 建立一個springboot專案,新增JWT依賴庫
<!-- jwt支援 -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>
  • 然後,建立一個使用者資訊類,將會透過加密存放在token
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserToken implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 使用者ID
     */
    private String userId;

    /**
     * 使用者登入賬戶
     */
    private String userNo;

    /**
     * 使用者中文名
     */
    private String userName;
}
  • 接著,建立一個JwtTokenUtil工具類,用於建立token、驗證token
public class JwtTokenUtil {

    //定義token返回頭部
    public static final String AUTH_HEADER_KEY = "Authorization";

    //token字首
    public static final String TOKEN_PREFIX = "Bearer ";

    //簽名金鑰
    public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x";
    
    //有效期預設為 2hour
    public static final Long EXPIRATION_TIME = 1000L*60*60*2;


    /**
     * 建立TOKEN
     * @param content
     * @return
     */
    public static String createToken(String content){
        return TOKEN_PREFIX + JWT.create()
                .withSubject(content)
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .sign(Algorithm.HMAC512(KEY));
    }

    /**
     * 驗證token
     * @param token
     */
    public static String verifyToken(String token) throws Exception {
        try {
            return JWT.require(Algorithm.HMAC512(KEY))
                    .build()
                    .verify(token.replace(TOKEN_PREFIX, ""))
                    .getSubject();
        } catch (TokenExpiredException e){
            throw new Exception("token已失效,請重新登入",e);
        } catch (JWTVerificationException e) {
            throw new Exception("token驗證失敗!",e);
        }
    }
}
  • 編寫配置類,允許跨域,並且建立一個許可權攔截器
@Slf4j
@Configuration
public class GlobalWebMvcConfig implements WebMvcConfigurer {
       /**
     * 重寫父類提供的跨域請求處理的介面
     * @param registry
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 新增對映路徑
        registry.addMapping("/**")
                // 放行哪些原始域
                .allowedOrigins("*")
                // 是否傳送Cookie資訊
                .allowCredentials(true)
                // 放行哪些原始域(請求方式)
                .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
                // 放行哪些原始域(頭部資訊)
                .allowedHeaders("*")
                // 暴露哪些頭部資訊(因為跨域訪問預設不能獲取全部頭部資訊)
                .exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials");
    }

    /**
     * 新增攔截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //新增許可權攔截器
        registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
    }
}
  • 使用AuthenticationInterceptor攔截器對介面引數進行驗證
@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 從http請求頭中取出token
        final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);
        //如果不是對映到方法,直接透過
        if(!(handler instanceof HandlerMethod)){
            return true;
        }
        //如果是方法探測,直接透過
        if (HttpMethod.OPTIONS.equals(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return true;
        }
        //如果方法有JwtIgnore註解,直接透過
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method=handlerMethod.getMethod();
        if (method.isAnnotationPresent(JwtIgnore.class)) {
            JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);
            if(jwtIgnore.value()){
                return true;
            }
        }
        LocalAssert.isStringEmpty(token, "token為空,鑑權失敗!");
        //驗證,並獲取token內部資訊
        String userToken = JwtTokenUtil.verifyToken(token);
        
        //將token放入本地快取
        WebContextUtil.setUserToken(userToken);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //方法結束後,移除快取的token
        WebContextUtil.removeUserToken();
    }
}
  • 最後,在controller層使用者登入之後,建立一個token,存放在頭部即可
/**
 * 登入
 * @param userDto
 * @return
 */
@JwtIgnore
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public UserVo login(@RequestBody UserDto userDto, HttpServletResponse response){
    //...引數合法性驗證

    //從資料庫獲取使用者資訊
    User dbUser = userService.selectByUserNo(userDto.getUserNo);

    //....使用者、密碼驗證

    //建立token,並將token放在響應頭
    UserToken userToken = new UserToken();
    BeanUtils.copyProperties(dbUser,userToken);

    String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
    response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);


    //定義返回結果
    UserVo result = new UserVo();
    BeanUtils.copyProperties(dbUser,result);
    return result;
}

到這裡基本就完成了!

其中AuthenticationInterceptor中用到的JwtIgnore是一個註解,用於不需要驗證token的方法上,例如驗證碼的獲取等等。

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtIgnore {

    boolean value() default true;
}

WebContextUtil是一個執行緒快取工具類,其他介面透過這個方法即可從token中獲取使用者資訊。

public class WebContextUtil {

    //本地執行緒快取token
    private static ThreadLocal<String> local = new ThreadLocal<>();

    /**
     * 設定token資訊
     * @param content
     */
    public static void setUserToken(String content){
        removeUserToken();
        local.set(content);
    }

    /**
     * 獲取token資訊
     * @return
     */
    public static UserToken getUserToken(){
        if(local.get() != null){
            UserToken userToken = JSONObject.parseObject(local.get() , UserToken.class);
            return userToken;
        }
        return null;
    }

    /**
     * 移除token資訊
     * @return
     */
    public static void removeUserToken(){
        if(local.get() != null){
            local.remove();
        }
    }
}

最後,啟動專案,我們來用postman測試一下,看看頭部返回結果。

我們把返回的資訊提取處理,使用瀏覽器的base64對前兩個部分進行解密。

  • 第一部分,也就是header,結果如下:

  • 第二部分,也就是playload,結果如下:

可以很清晰的看到,頭部、載荷的資訊都可以透過base64解密出來。

所以,一定別在token中存放敏感資訊

當我們需要請求其它服務介面時,只需要在請求頭部headers中加入Authorization引數即可。

當許可權攔截器驗證透過之後,在介面方法中只需要透過WebContextUtil工具類就可以獲取使用者資訊。

//獲取使用者token資訊
UserToken userToken = WebContextUtil.getUserToken();

四、總結

JWT相比session方案,因為json的通用性,所以JWT是可以進行跨語言支援的,像JAVAJavaScriptPHP等很多語言都可以使用,而session方案只針對JAVA

因為有了payload部分,所以JWT可以儲存一些其他業務邏輯所必要的非敏感資訊。

同時,保護好服務端secret私鑰非常重要,因為私鑰可以對資料進行驗證、解密。如果可以,請使用https協議!

專案原始碼地址如下!

https://gitee.com/pzblogs/spring-boot-example-demo

五、參考

1、簡書 - 什麼是 JWT -- JSON WEB TOKEN

2、部落格園 - 基於session和token的身份認證方案

相關文章