手把手教你,使用JWT實現單點登錄


參考網址:https://zhuanlan.zhihu.com/p/141065758

 

 

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

一、故事起源

說起 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加密后的header和base64加密后的payload使用.連接組成的字符串,然后通過header中聲明的加密方式進行加鹽secret組合加密,然后就構成了jwt的第三部分。

//javascriptvar 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@Configurationpublic 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攔截器對接口參數進行驗證
@Slf4jpublic 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是可以進行跨語言支持的,像JAVA、JavaScript、PHP等很多語言都可以使用,而session方案只針對JAVA。

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

同時,保護好服務端secret私鑰非常重要,因為私鑰可以對數據進行驗證、解密!

如果可以,請使用https協議!


免責聲明!

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



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