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,如果驗證通過就會返回相應的資源,整個流程就是這樣
總結
優點:
因為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()
:
整個請求處理完畢回調方法,該方法也是需要當前對應的Interceptor
的preHandle()
的返回值為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.0
及Spring 5.0
中WebMvcConfigurerAdapter
已被廢棄
網上有說改為繼承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
接口
我這里使用了統一異常處理,所以只看到錯誤message
下面進行登錄,從而獲取token
登錄操作我沒加驗證注解,所以可以直接訪問
把token
加在請求頭中,再次訪問api/getMessage
接口
注意:這里的key
一定不能錯,因為在攔截器中是取關鍵字token
的值
String token = httpServletRequest.getHeader("token");
加上token
之后就可以順利通過驗證和進行接口訪問了