手把手教你,使用JWT實(shí)現(xiàn)單點(diǎn)登錄
JSON Web Token(JWT)是目前最流行的跨域身份驗(yàn)證解決方案之一,今天我們一起來揭開它神秘的面紗!
一、故事起源
說起 JWT,我們先來談一談基于傳統(tǒng)session認(rèn)證的方案以及瓶頸。
傳統(tǒng)session交互流程,如下圖:
當(dāng)瀏覽器向服務(wù)器發(fā)送登錄請求時(shí),驗(yàn)證通過之后,會將用戶信息存入seesion中,然后服務(wù)器會生成一個(gè)sessionId放入cookie中,隨后返回給瀏覽器。
當(dāng)瀏覽器再次發(fā)送請求時(shí),會在請求頭部的cookie中放入sessionId,將請求數(shù)據(jù)一并發(fā)送給服務(wù)器。
服務(wù)器就可以再次從seesion獲取用戶信息,整個(gè)流程完畢!
通常在服務(wù)端會設(shè)置seesion的時(shí)長,例如 30 分鐘沒有活動(dòng),會將已經(jīng)存放的用戶信息從seesion中移除。
- session.setMaxInactiveInterval(30 * 60);//30分鐘沒活動(dòng),自動(dòng)移除
同時(shí),在服務(wù)端也可以通過seesion來判斷當(dāng)前用戶是否已經(jīng)登錄,如果為空表示沒有登錄,直接跳轉(zhuǎn)到登錄頁面;如果不為空,可以從session中獲取用戶信息即可進(jìn)行后續(xù)操作。
在單體應(yīng)用中,這樣的交互方式,是沒啥問題的。
但是,假如應(yīng)用服務(wù)器的請求量變得很大,而單臺服務(wù)器能支撐的請求量是有限的,這個(gè)時(shí)候就容易出現(xiàn)請求變慢或者OOM。
解決的辦法,要么給單臺服務(wù)器增加配置,要么增加新的服務(wù)器,通過負(fù)載均衡來滿足業(yè)務(wù)的需求。
如果是給單臺服務(wù)器增加配置,請求量繼續(xù)變大,依然無法支撐業(yè)務(wù)處理。
顯而易見,增加新的服務(wù)器,可以實(shí)現(xiàn)無限的水平擴(kuò)展。
但是增加新的服務(wù)器之后,不同的服務(wù)器之間的sessionId是不一樣的,可能在A服務(wù)器上已經(jīng)登錄成功了,能從服務(wù)器的session中獲取用戶信息,但是在B服務(wù)器上卻查不到session信息,此時(shí)肯定無比的尷尬,只好退出來繼續(xù)登錄,結(jié)果A服務(wù)器中的session因?yàn)槌瑫r(shí)失效,登錄之后又被強(qiáng)制退出來要求重新登錄,想想都挺尷尬~~
面對這種情況,幾位大佬于是合起來商議,想出了一個(gè)token方案。
將各個(gè)應(yīng)用程序與內(nèi)存數(shù)據(jù)庫redis相連,對登錄成功的用戶信息進(jìn)行一定的算法加密,生成的ID被稱為token,將token還有用戶的信息存入redis;等用戶再次發(fā)起請求的時(shí)候,將token還有請求數(shù)據(jù)一并發(fā)送給服務(wù)器,服務(wù)端驗(yàn)證token是否存在redis中,如果存在,表示驗(yàn)證通過,如果不存在,告訴瀏覽器跳轉(zhuǎn)到登錄頁面,流程結(jié)束。
token方案保證了服務(wù)的無狀態(tài),所有的信息都是存在分布式緩存中?;诜植际酱鎯Γ@樣可以水平擴(kuò)展來支持高并發(fā)。
當(dāng)然,現(xiàn)在springboot還提供了session共享方案,類似token方案將session存入到redis中,在集群環(huán)境下實(shí)現(xiàn)一次登錄之后,每個(gè)服務(wù)器都可以獲取到用戶信息。
二、JWT是什么
上文中,我們談到的session還有token的方案,在集群環(huán)境下,他們都是靠第三方緩存數(shù)據(jù)庫redis來實(shí)現(xiàn)數(shù)據(jù)的共享。
那有沒有一種方案,不用緩存數(shù)據(jù)庫redis來實(shí)現(xiàn)用戶信息的共享,以達(dá)到一次登錄,處處可見的效果呢?
答案肯定是有的,就是我們今天要介紹的JWT!
JWT全稱JSON Web Token,實(shí)現(xiàn)過程簡單的說就是用戶登錄成功之后,將用戶的信息進(jìn)行加密,然后生成一個(gè)token返回給客戶端,與傳統(tǒng)的session交互沒太大區(qū)別。
交互流程如下:
唯一的不同點(diǎn)就是:token存放了用戶的基本信息,更直觀一點(diǎn)就是將原本放入redis中的用戶數(shù)據(jù),放入到token中去了!
這樣一來,客戶端、服務(wù)端都可以從token中獲取用戶的基本信息,既然客戶端可以獲取,肯定是不能存放敏感信息的,因?yàn)闉g覽器可以直接從token獲取用戶信息。
JWT具體長什么樣呢?
JWT是由三段信息構(gòu)成的,將這三段信息文本用.鏈接一起就構(gòu)成了JWT字符串。就像這樣:
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
- 第一部分:我們稱它為頭部(header),用于存放token類型和加密協(xié)議,一般都是固定的;
- 第二部分:我們稱其為載荷(payload),用戶數(shù)據(jù)就存放在里面;
- 第三部分:是簽證(signature),主要用于服務(wù)端的驗(yàn)證;
1、header
JWT的頭部承載兩部分信息:
- 聲明類型,這里是JWT;
- 聲明加密的算法,通常直接使用 HMAC SHA256;
完整的頭部就像下面這樣的JSON:
- {
- 'typ': 'JWT',
- 'alg': 'HS256'
- }
使用base64加密,構(gòu)成了第一部分。
- eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
2、playload
載荷就是存放有效信息的地方,這些有效信息包含三個(gè)部分:
- 標(biāo)準(zhǔn)中注冊的聲明;
- 公共的聲明;
- 私有的聲明;
其中,標(biāo)準(zhǔn)中注冊的聲明 (建議但不強(qiáng)制使用)包括如下幾個(gè)部分 :
- iss: jwt簽發(fā)者;
- sub: jwt所面向的用戶;
- aud: 接收jwt的一方;
- exp: jwt的過期時(shí)間,這個(gè)過期時(shí)間必須要大于簽發(fā)時(shí)間;
- nbf: 定義在什么時(shí)間之前,該jwt都是不可用的;
- iat: jwt的簽發(fā)時(shí)間;
- jwt的唯一身份標(biāo)識,主要用來作為一次性token,從而回避重放攻擊;
公共的聲明部分:公共的聲明可以添加任何的信息,一般添加用戶的相關(guān)信息或其他業(yè)務(wù)需要的必要信息,但不建議添加敏感信息,因?yàn)樵摬糠衷诳蛻舳丝山饷堋?/p>
私有的聲明部分:私有聲明是提供者和消費(fèi)者所共同定義的聲明,一般不建議存放敏感信息,因?yàn)閎ase64是對稱解密的,意味著該部分信息可以歸類為明文信息。
定義一個(gè)payload:
- {
- "sub": "1234567890",
- "name": "John Doe",
- "admin": true
- }
然后將其進(jìn)行base64加密,得到Jwt的第二部分:
- eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
3、signature
jwt的第三部分是一個(gè)簽證信息,這個(gè)簽證信息由三部分組成:
- header (base64后的);
- payload (base64后的);
- secret (密鑰);
這個(gè)部分需要base64加密后的header和base64加密后的payload使用.連接組成的字符串,然后通過header中聲明的加密方式進(jìn)行加鹽secret組合加密,然后就構(gòu)成了jwt的第三部分。
- //javascript
- var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
- var signature = HMACSHA256(encodedString, '密鑰');
加密之后,得到signature簽名信息。
- TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
將這三部分用.連接成一個(gè)完整的字符串,就構(gòu)成了最終的jwt:
- //jwt最終格式
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
這個(gè)只是通過javascript實(shí)現(xiàn)的一個(gè)演示,JWT的簽發(fā)和密鑰的保存都是在服務(wù)端來完成。
secret用來進(jìn)行jwt的簽發(fā)和jwt的驗(yàn)證,所以,在任何場景都不應(yīng)該流露出去。
三、實(shí)戰(zhàn)
介紹了這么多,怎么實(shí)現(xiàn)呢?廢話不多說,下面我們直接開擼!
- 創(chuàng)建一個(gè)springboot項(xiàng)目,添加JWT依賴庫
- <!-- jwt支持 -->
- <dependency>
- <groupId>com.auth0</groupId>
- <artifactId>java-jwt</artifactId>
- <version>3.4.0</version>
- </dependency>
- 然后,創(chuàng)建一個(gè)用戶信息類,將會通過加密存放在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;
- }
- 接著,創(chuàng)建一個(gè)JwtTokenUtil工具類,用于創(chuàng)建token、驗(yàn)證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";
- //有效期默認(rèn)為 2hour
- public static final Long EXPIRATION_TIME = 1000L*60*60*2;
- /**
- * 創(chuàng)建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));
- }
- /**
- * 驗(yàn)證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驗(yàn)證失?。?quot;,e);
- }
- }
- }
- 編寫配置類,允許跨域,并且創(chuàng)建一個(gè)權(quán)限攔截器
- @Slf4j
- @Configuration
- public class GlobalWebMvcConfig implements WebMvcConfigurer {
- /**
- * 重寫父類提供的跨域請求處理的接口
- * @param registry
- */
- @Override
- public void addCorsMappings(CorsRegistry registry) {
- // 添加映射路徑
- registry.addMapping("/**")
- // 放行哪些原始域
- .allowedOrigins("*")
- // 是否發(fā)送Cookie信息
- .allowCredentials(true)
- // 放行哪些原始域(請求方式)
- .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
- // 放行哪些原始域(頭部信息)
- .allowedHeaders("*")
- // 暴露哪些頭部信息(因?yàn)榭缬蛟L問默認(rèn)不能獲取全部頭部信息)
- .exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials");
- }
- /**
- * 添加攔截器
- * @param registry
- */
- @Override
- public void addInterceptors(InterceptorRegistry registry) {
- //添加權(quán)限攔截器
- registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
- }
- }
- 使用AuthenticationInterceptor攔截器對接口參數(shù)進(jìn)行驗(yàn)證
- @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為空,鑒權(quán)失敗!");
- //驗(yàn)證,并獲取token內(nèi)部信息
- 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 {
- //方法結(jié)束后,移除緩存的token
- WebContextUtil.removeUserToken();
- }
- }
- 最后,在controller層用戶登錄之后,創(chuàng)建一個(gè)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){
- //...參數(shù)合法性驗(yàn)證
- //從數(shù)據(jù)庫獲取用戶信息
- User dbUser = userService.selectByUserNo(userDto.getUserNo);
- //....用戶、密碼驗(yàn)證
- //創(chuàng)建token,并將token放在響應(yīng)頭
- UserToken userToken = new UserToken();
- BeanUtils.copyProperties(dbUser,userToken);
- String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
- response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);
- //定義返回結(jié)果
- UserVo result = new UserVo();
- BeanUtils.copyProperties(dbUser,result);
- return result;
- }
到這里基本就完成了!
其中AuthenticationInterceptor中用到的JwtIgnore是一個(gè)注解,用于不需要驗(yàn)證token的方法上,例如驗(yàn)證碼的獲取等等。
- @Target({ElementType.METHOD, ElementType.TYPE})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface JwtIgnore {
- boolean value() default true;
- }
而WebContextUtil是一個(gè)線程緩存工具類,其他接口通過這個(gè)方法即可從token中獲取用戶信息。
- public class WebContextUtil {
- //本地線程緩存token
- private static ThreadLocal<String> local = new ThreadLocal<>();
- /**
- * 設(shè)置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();
- }
- }
- }
最后,啟動(dòng)項(xiàng)目,我們來用postman測試一下,看看頭部返回結(jié)果。
我們把返回的信息提取處理,使用瀏覽器的base64對前兩個(gè)部分進(jìn)行解密。
可以很清晰的看到,頭部、載荷的信息都可以通過base64解密出來。
所以,一定別在token中存放敏感信息!
當(dāng)我們需要請求其它服務(wù)接口時(shí),只需要在請求頭部headers中加入Authorization參數(shù)即可。
當(dāng)權(quán)限攔截器驗(yàn)證通過之后,在接口方法中只需要通過WebContextUtil工具類就可以獲取用戶信息。
- //獲取用戶token信息
- UserToken userToken = WebContextUtil.getUserToken();
四、總結(jié)
JWT相比session方案,因?yàn)閖son的通用性,所以JWT是可以進(jìn)行跨語言支持的,像JAVA、JavaScript、PHP等很多語言都可以使用,而session方案只針對JAVA。
因?yàn)橛辛藀ayload部分,所以JWT可以存儲一些其他業(yè)務(wù)邏輯所必要的非敏感信息。
同時(shí),保護(hù)好服務(wù)端secret私鑰非常重要,因?yàn)樗借€可以對數(shù)據(jù)進(jìn)行驗(yàn)證、解密!
如果可以,請使用https協(xié)議!
五、參考
1、簡書 - 什么是 JWT -- JSON WEB TOKEN
2、博客園 - 基于session和token的身份認(rèn)證方案