Json web token(JWT)是為了網(wǎng)絡(luò)應(yīng)用環(huán)境間傳遞聲明而執(zhí)行的一種基于JSON的開發(fā)標準(RFC 7519)砸紊,該token被設(shè)計為緊湊且安全的,特別適用于分布式站點的單點登陸(SSO)場景凤跑。JWT的聲明一般被用來在身份提供者和服務(wù)提供者間傳遞被認證的用戶身份信息缀遍,以便于從資源服務(wù)器獲取資源,也可以增加一些額外的其它業(yè)務(wù)邏輯所必須的聲明信息煮剧,該token也可直接被用于認證闻镶,也可被加密甚脉。
起源
說起JWT,我們應(yīng)該來談一談基于token的認證和傳統(tǒng)的Session認證的區(qū)別儒溉。
傳統(tǒng)的session認證
我們知道宦焦,http協(xié)議本身是一種無狀態(tài)的協(xié)議,而這就意味著如果用戶向我們的應(yīng)用提供了用戶名和密碼來進行用戶認證,那么下一次請求時波闹,用戶還要再一次進行用戶認證才行酝豪,因為根據(jù)http協(xié)議,我們并不能知道是哪個用戶發(fā)送的請求精堕,所以為了讓我們的應(yīng)用能識別是哪個用戶發(fā)出的孵淘,我們只能在服務(wù)器存儲一份用戶登陸的信息,這份登陸信息會在響應(yīng)時傳遞給服務(wù)器歹篓,告訴其保存為cookie瘫证,以便下次請求時發(fā)送給我們的應(yīng)用,這樣我們的英喲個就能識別請求來自哪個用戶了庄撮,這就是傳統(tǒng)的基于sessino認證
但是這種基于session的認證使應(yīng)用本身很難得擴展背捌,隨著不用客戶端的增加,獨立的服務(wù)器已無法承載更多的用戶洞斯,而這個時候基于session認證應(yīng)用的問題就會暴露出來
基于session認證所顯露的問題
Session:每個用戶經(jīng)過我們的應(yīng)用認證之后毡庆,我們的應(yīng)用都要在服務(wù)端做一次記錄,以便用戶下次請求的鑒別烙如,通常而言session都是保存在內(nèi)存中么抗,而隨著認證用戶的增多,服務(wù)端的開銷會明顯增大
擴展性:用戶認證之后亚铁,服務(wù)端做認證記錄蝇刀,如果認證的記錄被保存在內(nèi)存的話,這意味著用戶下次請求還必須要請求在這臺服務(wù)器上徘溢,這樣才能拿到授權(quán)的資源吞琐,這樣在分布式的應(yīng)用上,響應(yīng)的限制了負載均衡器的能力甸昏,也意味著限制了應(yīng)用的擴展性
CSRF:因為是基于cookie來進行用戶識別的顽分,cookie如果被截獲,用戶就會很容易受到跨站請求偽造的攻擊施蜜。
基于token的鑒權(quán)機制
基于token的鑒權(quán)機制類似于http協(xié)議也是無狀態(tài)的,它不需要在服務(wù)端去保留用戶的認證信息或會話信息雌隅。這也就意味著機遇tokent認證機制的應(yīng)用不需要去考慮用戶在哪一臺服務(wù)器登陸了翻默,這就為應(yīng)用的擴展提供了便利
流程是這樣的
用戶使用用戶名密碼請求服務(wù)器
服務(wù)器進行驗證用戶信息
服務(wù)器通過驗證發(fā)送給用戶一個token
客戶端存儲token,并在每次請求時附加這個token值
-
服務(wù)器驗證token恰起,并返回數(shù)據(jù)
這個token必須要在每次請求時發(fā)送給服務(wù)器修械,它應(yīng)該保存在請求頭中,另外检盼,服務(wù)器要支持CORS(跨來源資源共享)策略肯污,一般我們在服務(wù)端這么做就可以了 Access-Control-Allow-Origin:*
JWT的構(gòu)成
JWT是由三部分構(gòu)成,將這三段信息文本用鏈接構(gòu)成了JWT字符串。就像這樣
<pre>eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiJ9.Qjw1epD5P6p4Yy2yju3-fkq28PddznqRj3ESfALQy_U</pre>
第一部分我們稱它為頭部(header)第二部分我們稱其為載荷(payload蹦渣,類似于飛機上承載的物品)哄芜,第三部分是簽證(signature)
header
JWT的頭部承載的兩部分信息:
- 聲明類型,這里是jwt
- 聲明加密的算法柬唯,通常直接使用HMAC SHA256
完整的頭部就像下面這樣的JSON
<pre>{ 'typ':'JWT', 'alg':'HS256' }</pre>
然后將頭部進行base64加密(該加密是可以對稱解密的)认臊,構(gòu)成了第一部分
<pre>eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9</pre>
plyload
載荷就是存放有效信息的地方。這個名字像是特指飛機上承載的貨品锄奢,這些有效信息包含三個部分
標準中注冊的聲明
公共的聲明
-
私有的聲明
標注中注冊的聲明(建議不強制使用)
iss:jwt簽發(fā)者
sub:jwt所面向的用戶
aud:接收jwt的一方
exp:jwt的過期時間失晴,這個過期時間必須大于簽發(fā)時間
nbf:定義在什么時間之前,該jwt都是不可用的
iat:jwt的簽發(fā)時間
-
jti:jwt的唯一身份標識拘央,主要用來作為一次性token涂屁,從而回避重放攻擊
公共的聲明:
公共的聲明可以添加任何的信息,一般添加用戶的相關(guān)信息或其它業(yè)務(wù)需要的必要信息灰伟,但不建議添加敏感信息胯陋,因為該部分在客戶端可解密;
私有的聲明
私有的聲明是提供者和消費者功能定義的聲明袱箱,一般不建議存放敏感信息遏乔,因為base64是對稱解密的,意味著該部分信息可以歸類為名文信息发笔。
定義一個payload
<pre>{ "sub": "1234567890", "name": "John Doe", "admin": true }</pre>
然后將其base64加密盟萨,得到j(luò)wt的一部分
<pre>eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9</pre>
Signature
jwt的第三部分是一個簽證信息,這個簽證信息由三部分組成:
header(base64后的)
payload(base64后的)
-
secred
這個部分需要base64加密后的header和base64加密后的payload使用“.”連接組成的字符串了讨,然后通過header中聲明的加密方式進行加secret組合加密捻激,然后就構(gòu)成了jwt的第三部分
<pre>var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ</pre>
將這三部分用“.”連接成一個完整的字符串,構(gòu)成了最終的jwt:
<pre>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ</pre>
注意:secret是保存在服務(wù)器端的前计,jwt的簽發(fā)也是在服務(wù)端的胞谭,secret就是用來進行jwt的簽發(fā)和jwt的驗證,所以它就是你服務(wù)端的私鑰男杈,在任何場景都不應(yīng)該流露出去丈屹,一旦客戶端得知這個secret,那就意味著客戶端可以自我簽發(fā)jwt了
應(yīng)用
一般是在請求頭里加入Authorization伶棒,并加上Bearer標注:
<pre>fetch('api/user/1', {
headers: { 'Authorization': 'Bearer ' + token
}
})</pre>
服務(wù)端會驗證token旺垒,如果驗證通過就會返回相應(yīng)的資源,整個流程就是這樣
總結(jié)
優(yōu)點:
因為json的通用性肤无,所以JWT是可以跨語言支持的先蒋,像C#,JavaScript宛渐,NodeJS竞漾,PHP等許多語言都可以使用
因為由了payload部分都哭,所以JWT可以在自身存儲一些其它業(yè)務(wù)邏輯所必要的非敏感信息
便于傳輸匕累,jwt的構(gòu)成非常簡單,字節(jié)占用很小,所以它是非常便于傳輸?shù)?/p>
-
它不需要在服務(wù)端保存會話信息簿废,所以它易于應(yīng)用的擴展
安全相關(guān)
不應(yīng)該在jwt的payload部分存儲敏感信息暑竟,因為該部分是客戶端可解密的部分
保護好secret私鑰床蜘。該私鑰非常重要
如果可以晰洒,請使用https協(xié)議
代碼實現(xiàn)
下面來進行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)
——方法參數(shù)
@Target(ElementType.CONSTRUCTOR)
——構(gòu)造函數(shù)
@Target(ElementType.LOCAL_VARIABLE)
——局部變量
@Target(ElementType.ANNOTATION_TYPE)
——注解
@Target(ElementType.PACKAGE)
——包
@Retention
:注解的保留位置
RetentionPolicy.SOURCE
:這種類型的Annotations
只在源代碼級別保留,編譯時就會被忽略,在class
字節(jié)碼文件中不包含提澎。
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
,密鑰則是用戶的密碼盼忌,唯一密鑰的話可以保存在服務(wù)端。
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;
}
}
//檢查有沒有需要用戶權(quán)限的注解
if (method.isAnnotationPresent(UserLoginToken.class)) {
UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class);
if (userLoginToken.required()) {
// 執(zhí)行認證
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 {
}
實現(xiàn)一個攔截器就需要實現(xiàn)HandlerInterceptor
接口
HandlerInterceptor
接口主要定義了三個方法
1.boolean preHandle ()
:
預(yù)處理回調(diào)方法,實現(xiàn)處理器的預(yù)處理跨嘉,第三個參數(shù)為響應(yīng)的處理器,自定義Controller
,返回值為true
表示繼續(xù)流程(如調(diào)用下一個攔截器或處理器)或者接著執(zhí)行postHandle()
和afterCompletion()
;false
表示流程中斷吃嘿,不會繼續(xù)調(diào)用其他的攔截器或處理器祠乃,中斷執(zhí)行。
2.void postHandle()
:
后處理回調(diào)方法兑燥,實現(xiàn)處理器的后處理(DispatcherServlet
進行視圖返回渲染之前進行調(diào)用)亮瓷,此時我們可以通過modelAndView
(模型和視圖對象)對模型數(shù)據(jù)進行處理或?qū)σ晥D進行處理,modelAndView
也可能為null
降瞳。
3.void afterCompletion()
:
整個請求處理完畢回調(diào)方法,該方法也是需要當前對應(yīng)的Interceptor
的preHandle()
的返回值為true時才會執(zhí)行嘱支,也就是在DispatcherServlet
渲染了對應(yīng)的視圖之后執(zhí)行。用于進行資源清理力崇。整個請求處理完畢回調(diào)方法斗塘。如性能監(jiān)控中我們可以在此記錄結(jié)束時間并輸出消耗時間,還可以進行一些資源清理亮靴,類似于try-catch-finally
中的finally
,但僅調(diào)用處理器執(zhí)行鏈中
主要流程:
1.從 http
請求頭中取出 token
于置,
2.判斷是否映射到方法
3.檢查是否有passtoken
注釋茧吊,有則跳過認證
4.檢查有沒有需要用戶登錄的注解贞岭,有則需要取出并驗證
5.認證通過則可以訪問,不通過會報相關(guān)錯誤信息
配置攔截器
在配置類上添加了注解@Configuration
搓侄,標明了該類是一個配置類并且會將該類作為一個SpringBean
添加到IOC
容器內(nèi)
@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
該抽象類其實里面沒有任何的方法實現(xiàn),只是空實現(xiàn)了接口
WebMvcConfigurer
內(nèi)的全部方法讶踪,并沒有給出任何的業(yè)務(wù)邏輯處理芯侥,這一點設(shè)計恰到好處的讓我們不必去實現(xiàn)那些我們不用的方法,都交由WebMvcConfigurerAdapter
抽象類空實現(xiàn),如果我們需要針對具體的某一個方法做出邏輯處理,僅僅需要在WebMvcConfigurerAdapter
子類中@Override
對應(yīng)方法就可以了乳讥。
注:
在SpringBoot2.0
及Spring 5.0
中WebMvcConfigurerAdapter
已被廢棄
網(wǎng)上有說改為繼承WebMvcConfigurationSupport()
柱查,不過試了下,還是過期的
解決方法:
直接實現(xiàn)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
內(nèi)的addInterceptor
需要一個實現(xiàn)HandlerInterceptor
接口的攔截器實例云石,addPathPatterns
方法用于設(shè)置攔截器的過濾路徑規(guī)則唉工。
這里我攔截所有請求,通過判斷是否有@LoginRequired
注解 決定是否需要登錄
在數(shù)據(jù)訪問接口中加入登錄操作注解
@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
接口
我這里使用了統(tǒng)一異常處理铅乡,所以只看到錯誤message
下面進行登錄继谚,從而獲取token
登錄操作我沒加驗證注解,所以可以直接訪問
把token
加在請求頭中隆判,再次訪問api/getMessage
接口
注意:這里的key
一定不能錯犬庇,因為在攔截器中是取關(guān)鍵字token
的值
String token = httpServletRequest.getHeader("token");
加上token
之后就可以順利通過驗證和進行接口訪問了