JWT

Json web token(JWT)是為了網絡應用環境間傳遞聲明而執行的一種基于JSON的開發標準(RFC 7519),該token被設計為緊湊且安全的,特別適用于分布式站點的單點登陸(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便于從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用于認證,也可被加密。

起源

說起JWT,我們應該來談一談基于token的認證和傳統的Session認證的區別。

傳統的session認證

我們知道,http協議本身是一種無狀態的協議,而這就意味著如果用戶向我們的應用提供了用戶名和密碼來進行用戶認證,那么下一次請求時,用戶還要再一次進行用戶認證才行,因為根據http協議,我們并不能知道是哪個用戶發送的請求,所以為了讓我們的應用能識別是哪個用戶發出的,我們只能在服務器存儲一份用戶登陸的信息,這份登陸信息會在響應時傳遞給服務器,告訴其保存為cookie,以便下次請求時發送給我們的應用,這樣我們的英喲個就能識別請求來自哪個用戶了,這就是傳統的基于sessino認證

但是這種基于session的認證使應用本身很難得擴展,隨著不用客戶端的增加,獨立的服務器已無法承載更多的用戶,而這個時候基于session認證應用的問題就會暴露出來

基于session認證所顯露的問題

Session:每個用戶經過我們的應用認證之后,我們的應用都要在服務端做一次記錄,以便用戶下次請求的鑒別,通常而言session都是保存在內存中,而隨著認證用戶的增多,服務端的開銷會明顯增大

擴展性:用戶認證之后,服務端做認證記錄,如果認證的記錄被保存在內存的話,這意味著用戶下次請求還必須要請求在這臺服務器上,這樣才能拿到授權的資源,這樣在分布式的應用上,響應的限制了負載均衡器的能力,也意味著限制了應用的擴展性

CSRF:因為是基于cookie來進行用戶識別的,cookie如果被截獲,用戶就會很容易受到跨站請求偽造的攻擊。

基于token的鑒權機制

基于token的鑒權機制類似于http協議也是無狀態的,它不需要在服務端去保留用戶的認證信息或會話信息。這也就意味著機遇tokent認證機制的應用不需要去考慮用戶在哪一臺服務器登陸了,這就為應用的擴展提供了便利

流程是這樣的

  • 用戶使用用戶名密碼請求服務器

  • 服務器進行驗證用戶信息

  • 服務器通過驗證發送給用戶一個token

  • 客戶端存儲token,并在每次請求時附加這個token值

  • 服務器驗證token,并返回數據

    這個token必須要在每次請求時發送給服務器,它應該保存在請求頭中,另外,服務器要支持CORS(跨來源資源共享)策略,一般我們在服務端這么做就可以了 Access-Control-Allow-Origin:*

JWT的構成

JWT是由三部分構成,將這三段信息文本用鏈接構成了JWT字符串。就像這樣

<pre>eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiJ9.Qjw1epD5P6p4Yy2yju3-fkq28PddznqRj3ESfALQy_U</pre>

第一部分我們稱它為頭部(header)第二部分我們稱其為載荷(payload,類似于飛機上承載的物品),第三部分是簽證(signature)

header

JWT的頭部承載的兩部分信息:

  • 聲明類型,這里是jwt
  • 聲明加密的算法,通常直接使用HMAC SHA256

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

<pre>{ 'typ':'JWT', 'alg':'HS256' }</pre>

然后將頭部進行base64加密(該加密是可以對稱解密的),構成了第一部分

<pre>eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9</pre>

plyload

載荷就是存放有效信息的地方。這個名字像是特指飛機上承載的貨品,這些有效信息包含三個部分

  • 標準中注冊的聲明

  • 公共的聲明

  • 私有的聲明

    標注中注冊的聲明(建議不強制使用)

  • iss:jwt簽發者

  • sub:jwt所面向的用戶

  • aud:接收jwt的一方

  • exp:jwt的過期時間,這個過期時間必須大于簽發時間

  • nbf:定義在什么時間之前,該jwt都是不可用的

  • iat:jwt的簽發時間

  • jti:jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊

    公共的聲明:

    公共的聲明可以添加任何的信息,一般添加用戶的相關信息或其它業務需要的必要信息,但不建議添加敏感信息,因為該部分在客戶端可解密;

    私有的聲明

    私有的聲明是提供者和消費者功能定義的聲明,一般不建議存放敏感信息,因為base64是對稱解密的,意味著該部分信息可以歸類為名文信息。

    定義一個payload

<pre>{ "sub": "1234567890", "name": "John Doe", "admin": true }</pre>

然后將其base64加密,得到jwt的一部分

<pre>eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9</pre>

Signature

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

  • header(base64后的)

  • payload(base64后的)

  • secred

    這個部分需要base64加密后的header和base64加密后的payload使用“.”連接組成的字符串,然后通過header中聲明的加密方式進行加secret組合加密,然后就構成了jwt的第三部分

<pre>var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ</pre>

將這三部分用“.”連接成一個完整的字符串,構成了最終的jwt:

<pre>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ</pre>

注意:secret是保存在服務器端的,jwt的簽發也是在服務端的,secret就是用來進行jwt的簽發和jwt的驗證,所以它就是你服務端的私鑰,在任何場景都不應該流露出去,一旦客戶端得知這個secret,那就意味著客戶端可以自我簽發jwt了

應用

一般是在請求頭里加入Authorization,并加上Bearer標注:

<pre>fetch('api/user/1', {
headers: { 'Authorization': 'Bearer ' + token
}
})</pre>

服務端會驗證token,如果驗證通過就會返回相應的資源,整個流程就是這樣

image

總結

優點:

  • 因為json的通用性,所以JWT是可以跨語言支持的,像C#,JavaScript,NodeJS,PHP等許多語言都可以使用

  • 因為由了payload部分,所以JWT可以在自身存儲一些其它業務邏輯所必要的非敏感信息

  • 便于傳輸,jwt的構成非常簡單,字節占用很小,所以它是非常便于傳輸的

  • 它不需要在服務端保存會話信息,所以它易于應用的擴展

    安全相關

  • 不應該在jwt的payload部分存儲敏感信息,因為該部分是客戶端可解密的部分

  • 保護好secret私鑰。該私鑰非常重要

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

代碼實現

下面來進行SpringBoot和JWT的集成

引入JWT依賴,由于是基于Java,所以需要的是java-jwt

<dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>3.4.0</version>
</dependency>

需要自定義兩個注解

用來跳過驗證的PassToken

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
    boolean required() default true;
}

需要登錄才能進行操作的注解UserLoginToken

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface UserLoginToken {
    boolean required() default true;
}

@Target:注解的作用目標

@Target(ElementType.TYPE)——接口、類、枚舉、注解
@Target(ElementType.FIELD)——字段、枚舉的常量
@Target(ElementType.METHOD)——方法
@Target(ElementType.PARAMETER)——方法參數
@Target(ElementType.CONSTRUCTOR) ——構造函數
@Target(ElementType.LOCAL_VARIABLE)——局部變量
@Target(ElementType.ANNOTATION_TYPE)——注解
@Target(ElementType.PACKAGE)——包

@Retention:注解的保留位置

RetentionPolicy.SOURCE:這種類型的Annotations只在源代碼級別保留,編譯時就會被忽略,在class字節碼文件中不包含。
RetentionPolicy.CLASS:這種類型的Annotations編譯時被保留,默認的保留策略,在class文件中存在,但JVM將會忽略,運行時無法獲得。
RetentionPolicy.RUNTIME:這種類型的Annotations將被JVM保留,所以他們能在運行時被JVM或其他使用反射機制的代碼所讀取和使用。

@Document:說明該注解將被包含在javadoc
@Inherited:說明子類可以繼承父類中的該注解

簡單自定義一個實體類User,使用lombok簡化實體類的編寫

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    String Id;
    String username;
    String password;
}

需要寫token的生成方法

public String getToken(User user) {
        String token="";
        token= JWT.create().withAudience(user.getId())
                .sign(Algorithm.HMAC256(user.getPassword()));
        return token;
    }

Algorithm.HMAC256():使用HS256生成token,密鑰則是用戶的密碼,唯一密鑰的話可以保存在服務端。
withAudience()存入需要保存在token的信息,這里我把用戶ID存入token

接下來需要寫一個攔截器去獲取token并驗證token

public class AuthenticationInterceptor implements HandlerInterceptor {
    @Autowired
    UserService userService;
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
        String token = httpServletRequest.getHeader("token");// 從 http 請求頭中取出 token
        // 如果不是映射到方法直接通過
        if(!(object instanceof HandlerMethod)){
            return true;
        }
        HandlerMethod handlerMethod=(HandlerMethod)object;
        Method method=handlerMethod.getMethod();
        //檢查是否有passtoken注釋,有則跳過認證
        if (method.isAnnotationPresent(PassToken.class)) {
            PassToken passToken = method.getAnnotation(PassToken.class);
            if (passToken.required()) {
                return true;
            }
        }
        //檢查有沒有需要用戶權限的注解
        if (method.isAnnotationPresent(UserLoginToken.class)) {
            UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class);
            if (userLoginToken.required()) {
                // 執行認證
                if (token == null) {
                    throw new RuntimeException("無token,請重新登錄");
                }
                // 獲取 token 中的 user id
                String userId;
                try {
                    userId = JWT.decode(token).getAudience().get(0);
                } catch (JWTDecodeException j) {
                    throw new RuntimeException("401");
                }
                User user = userService.findUserById(userId);
                if (user == null) {
                    throw new RuntimeException("用戶不存在,請重新登錄");
                }
                // 驗證 token
                JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build();
                try {
                    jwtVerifier.verify(token);
                } catch (JWTVerificationException e) {
                    throw new RuntimeException("401");
                }
                return true;
            }
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, 
                                  HttpServletResponse httpServletResponse, 
                            Object o, ModelAndView modelAndView) throws Exception {

    }
    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, 
                                          HttpServletResponse httpServletResponse, 
                                          Object o, Exception e) throws Exception {
    }

實現一個攔截器就需要實現HandlerInterceptor接口

HandlerInterceptor接口主要定義了三個方法
1.boolean preHandle ()
預處理回調方法,實現處理器的預處理,第三個參數為響應的處理器,自定義Controller,返回值為true表示繼續流程(如調用下一個攔截器或處理器)或者接著執行postHandle()afterCompletion()false表示流程中斷,不會繼續調用其他的攔截器或處理器,中斷執行。

2.void postHandle()
后處理回調方法,實現處理器的后處理(DispatcherServlet進行視圖返回渲染之前進行調用),此時我們可以通過modelAndView(模型和視圖對象)對模型數據進行處理或對視圖進行處理,modelAndView也可能為null

3.void afterCompletion():
整個請求處理完畢回調方法,該方法也是需要當前對應的InterceptorpreHandle()的返回值為true時才會執行,也就是在DispatcherServlet渲染了對應的視圖之后執行。用于進行資源清理。整個請求處理完畢回調方法。如性能監控中我們可以在此記錄結束時間并輸出消耗時間,還可以進行一些資源清理,類似于try-catch-finally中的finally,但僅調用處理器執行鏈中

主要流程:

1.從 http 請求頭中取出 token
2.判斷是否映射到方法
3.檢查是否有passtoken注釋,有則跳過認證
4.檢查有沒有需要用戶登錄的注解,有則需要取出并驗證
5.認證通過則可以訪問,不通過會報相關錯誤信息

配置攔截器

在配置類上添加了注解@Configuration,標明了該類是一個配置類并且會將該類作為一個SpringBean添加到IOC容器內

@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor())
                .addPathPatterns("/**");    // 攔截所有請求,通過判斷是否有 @LoginRequired 注解 決定是否需要登錄
    }
    @Bean
    public AuthenticationInterceptor authenticationInterceptor() {
        return new AuthenticationInterceptor();
    }
}

WebMvcConfigurerAdapter該抽象類其實里面沒有任何的方法實現,只是空實現了接口
WebMvcConfigurer內的全部方法,并沒有給出任何的業務邏輯處理,這一點設計恰到好處的讓我們不必去實現那些我們不用的方法,都交由WebMvcConfigurerAdapter抽象類空實現,如果我們需要針對具體的某一個方法做出邏輯處理,僅僅需要在WebMvcConfigurerAdapter子類中@Override對應方法就可以了。

注:
SpringBoot2.0Spring 5.0WebMvcConfigurerAdapter已被廢棄
網上有說改為繼承WebMvcConfigurationSupport(),不過試了下,還是過期的

解決方法:

直接實現WebMvcConfigurer (官方推薦)

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor())
                .addPathPatterns("/**");   
    }
    @Bean
    public AuthenticationInterceptor authenticationInterceptor() {
        return new AuthenticationInterceptor();
    }
}

InterceptorRegistry內的addInterceptor需要一個實現HandlerInterceptor接口的攔截器實例,addPathPatterns方法用于設置攔截器的過濾路徑規則。
這里我攔截所有請求,通過判斷是否有@LoginRequired注解 決定是否需要登錄

在數據訪問接口中加入登錄操作注解

@RestController
@RequestMapping("api")
public class UserApi {
    @Autowired
    UserService userService;
    @Autowired
    TokenService tokenService;
    //登錄
    @PostMapping("/login")
    public Object login(@RequestBody User user){
        JSONObject jsonObject=new JSONObject();
        User userForBase=userService.findByUsername(user);
        if(userForBase==null){
            jsonObject.put("message","登錄失敗,用戶不存在");
            return jsonObject;
        }else {
            if (!userForBase.getPassword().equals(user.getPassword())){
                jsonObject.put("message","登錄失敗,密碼錯誤");
                return jsonObject;
            }else {
                String token = tokenService.getToken(userForBase);
                jsonObject.put("token", token);
                jsonObject.put("user", userForBase);
                return jsonObject;
            }
        }
    }
    @UserLoginToken
    @GetMapping("/getMessage")
    public String getMessage(){
        return "你已通過驗證";
    }
}

不加注解的話默認不驗證,登錄接口一般是不驗證的。在getMessage()中我加上了登錄注解,說明該接口必須登錄獲取token后,在請求頭中加上token并通過驗證才可以訪問

下面進行測試,啟動項目,使用postman測試接口

在沒token的情況下訪問api/getMessage接口

image

我這里使用了統一異常處理,所以只看到錯誤message

下面進行登錄,從而獲取token

image

登錄操作我沒加驗證注解,所以可以直接訪問

token加在請求頭中,再次訪問api/getMessage接口

image

注意:這里的key一定不能錯,因為在攔截器中是取關鍵字token的值
String token = httpServletRequest.getHeader("token");
加上token之后就可以順利通過驗證和進行接口訪問了

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,250評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,923評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,041評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,475評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,253評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,801評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,882評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,023評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,530評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,494評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,639評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,177評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,890評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,289評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,552評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,242評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,626評論 2 370

推薦閱讀更多精彩內容