本篇所使用的雖然不是標準的 Jwt Token ,重點不在此,而是 Spring 的攔截器使用。
Jwt Token 是一串字符串,分三個部分:
例如:
1 dWlkPTEmZXhwaXJlPTE1OTY0MTg0MDExMDAmdHlwZT10ZXN0JnVzZXI9MTIz.cGVybWl0dGVkPXRlc3Qmcm9sZXM9YSUyQ2IlMkNjJTJDZCUyQ2Umc3BlY2lhbD1zb21ldGhpbmc=.kG-dsK2shfLpOvPgO2VnxLfCcMoryqmBKg1WqosDNK4
三個部分都需要使用 Base64 for Url 來編碼內容。Jwt Token 使用 .
來分割這三部分。
簽名用於確認 token 的合法性,是可選的,但建議都帶上這個部分,這樣 Token 就無法被僞造。簽名是前兩個部分用 .
分割后,加上一個密碼作散列計算,之後把結果拼接在尾部。
我這條 Token 是使用 sha265 的方式作簽名,可以根據自己的需求來使用不同的散列函數。
基本思路 Spring 的攔截器可以對請求做很多的事情。
所有的請求進入應用,都需要入站後被處理才能到達 Controller 並讓業務邏輯代碼作相應的處理動作。簡單來說,入站之後就是 Servlet 。
1 (inbound) -> (Servlet) -> DispatcherServlet -> (Servlet) -> (outbound)
Spring DispatcherServlet 會處理所有的 Servlet 請求,所以 Servlet 這一層基本上可以不用管。
進入 DispatcherServlet 之後,對開發者來說事情就變得十分的簡單:
1 (inbound) -> Interceptor -> (ContentResolver) -> Controller -> Interceptor -> (ContentResolver) -> (outbound)
只要配置好 Intecpetor 就能攔截自己想要提前處理的請求。一般來說繼承 HandlerInterceptorAdapter.class
即可,主要用到的方法只有一個:
1 2 3 4 5 6 7 8 9 10 public abstract class HandlerInterceptorAdapter implements AsyncHandlerInterceptor { public HandlerInterceptorAdapter () { } public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true ; } }
preHandle(...)
發生在 Controller 執行之前,它的返回值是一個 Boolean 。true 代表允許繼續執行,請求會繼續傳遞到下一個攔截器(如果有)或者直接到達請求方法;false 代表從這裏結束請求,而返回的內容則通過 response 的 function 來操作。這是實現權限控制的關鍵點,畢竟權限控制也就是“如果有權限就執行沒權限就不執行”。
handler 一般傳入的是 HandlerMethod.class
,可以獲取到與請求對應的 Cotroller 上的 Java Method。它是個包裹類型,外加了一些很便利的 Function 去獲取 Controller Method 的信息。
request 傳入的是基本的 ServletRequest, 能獲取到請求的幾乎所有信息。
response 傳入的是基本的 ServletResponse,因爲這個 Method 在 Controller 之前,所以它並不能操作 Controller 所返回的內容,但可以修改它以在 Controller 處理完之後一併把東西返回給客戶端。
Token 塞哪裏 Token 是要塞在請求頭上面的。
Http 請求分成兩部分:請求頭和請求體。在 Interceptor 內,它們都能獲取到,但有所區別:
請求頭已經預先被讀取,所以內容可以直接通過方法讀取(例如請求頭的字段和內容)
請求體會延遲讀取,需要從 request 裏取得請求體的流並處理一遍才能讀取內容。
把 Http 請求看成一個信封就很好解釋了。
請求頭是信封上的標識,讓人一眼看得到這個信封大概是幹什麼的。
請求體是信封的內容,拆開信封才能看。
當然,其中的原因還有很多,不過就沒必要在這裏說明了。
實現 我這裏把 Token 的解釋和權限的處理拆分成兩個步驟。首先實現的是 Token 的解釋。
解析 Token 新建一個 Class 直接繼承它之後,覆寫 preHandle(...)
。打上 @Component
註解讓 Spring 自動初始化它
1 2 3 4 5 6 7 8 9 10 11 @Component public class TokenCheckerInterceptor extends HandlerInterceptorAdapter { private final TokenSecurityConfiguration tokenSecurityConfiguration; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { } }
頭信息能從 request 裏取出來,通過 getHeader(String)
方法。因爲有可能頭上沒有這個數據,所以需要判斷一下是否爲空。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 String header = request.getHeader("Token" );if (StringUtils.isEmpty(header)) return true ;String secret = tokenSecurityConfiguration.getTokenSecret();JwtToken token = JwtToken.parse(header);if (!token.verifySign(secret)) return true ;TokenHead head = TokenHead.fromPayload(token.getHeadBase64());long currentDate = System.currentTimeMillis();if (head.getExpireDate() < currentDate) { logger.debug( "Expired token from [{}({})] with type [{}] will not be accepted, token: {}" , head.getUserId(), head.getUsername(), head.getType(), header ); return true ; } SecurityTokenUtils.setToken(request, token); SecurityTokenUtils.setTokenHead(request, head); return true ;
當簽名校驗失敗或者 Token 過期的時候,也直接 pass,但並不會解釋 Token 。這樣在後面的攔截器裏就能通過這點簡單判斷一下請求是否合法。
善用 request.setAttribute(String,Object) SecurityTokenUtils.setToken(...)
和 SecurityTokenUtils.setTokenHead(...)
是兩個自己寫的方法。它僅僅是把解釋后的 Token 信息放置到 request 的 attribute 列表裏。每次請求都會帶這樣一個 attribute list,可以利用它往下存儲一些信息。其實 SpringMVC 的很多功能都是通過在這個列表裏存取信息實現的。需要注意的是自己所塞的內容的 key 不能跟框架的相同,這樣會引發異常行爲。簡單的解決方法是按照 java 的命名空間規範來設置存儲的 key ,直接使用包名是最簡單的方法。我這裏爲了簡單就直接使用了 security.* 這個命名空間,一般也沒有框架會這麼直接地用它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public final class SecurityTokenUtils { public final static String ATTRIBUTE_TOKEN = "security.token" ; public final static String ATTRIBUTE_TOKEN_HEAD = "security.token.head" ; static void setToken (HttpServletRequest request, JwtToken token) { request.setAttribute(ATTRIBUTE_TOKEN, token); } static void setTokenHead (HttpServletRequest request, TokenHead head) { request.setAttribute(ATTRIBUTE_TOKEN_HEAD, head); } }
存取這個東西的時候,把 key 作爲靜態字段存儲起來是最好的,這樣可以防止打錯字。
我還用了一個自己寫的 JwtToken.class
來解釋和存放 Token 信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 public class JwtToken { private static final int TOKEN_HEAD = 0 ; private static final int TOKEN_PAYLOAD = 1 ; private static final int TOKEN_SIGN = 2 ; private String headBase64; private String payloadBase64; private String signBase64 = null ; private JwtToken () { } public boolean verifySign (String secret) { String content = headBase64 + "." + payloadBase64; return Base64.encodeBase64URLSafeString(DigestUtils.sha256( secret == null ? content : (content + secret) )) .equals(signBase64); } public boolean verifySign () { return verifySign(null ); } public void sign (String secret) { String content = headBase64 + "." + payloadBase64; signBase64 = Base64.encodeBase64URLSafeString(DigestUtils.sha256( secret == null ? content : (content + secret) )); } public void sign () { sign(null ); } public String toString () { return headBase64 + "." + payloadBase64 + (signBase64 == null ? "" : ("." + signBase64)); } public static JwtToken fromBinary (byte [] head, byte [] payload, byte [] sign) { JwtToken token = new JwtToken (); token.headBase64 = Base64.encodeBase64String(head); token.payloadBase64 = Base64.encodeBase64String(payload); token.signBase64 = Base64.encodeBase64String(sign); return token; } public static JwtToken newToken (byte [] head, byte [] payload) { JwtToken token = new JwtToken (); token.headBase64 = Base64.encodeBase64String(head); token.payloadBase64 = Base64.encodeBase64String(payload); return token; } public static JwtToken parse (String stringToken) { JwtToken token = new JwtToken (); String[] s = stringToken.split("\\." ); if (s.length != 3 ) throw new IllegalStateException ("Invalid Jwt Token" ); token.headBase64 = s[TOKEN_HEAD]; token.payloadBase64 = s[TOKEN_PAYLOAD]; token.signBase64 = s[TOKEN_SIGN]; return token; } }
TokenHead.class
用來存放解釋后的 Jwt Token 的頭信息,也定義了一些 function 方便構建和解釋它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public class TokenHead { private String userId; private String username; private String type; private Long expireDate; public String toUrlParams () { Map<String,String> params = new HashMap <>(); params.put("uid" , userId); params.put("type" , type); params.put("user" , username); params.put("expire" , String.valueOf(expireDate)); return UrlParams.encodeParamMap(params); } public static TokenHead fromPayload (String headBase64) { String content = new String (Base64.getDecoder().decode(headBase64)); Map<String,String> params = UrlParams.parseParamMap(content); TokenHead tokenHead = new TokenHead (); tokenHead.setUserId(params.get("uid" )); tokenHead.setType(params.get("type" )); tokenHead.setUsername(params.get("user" )); try { tokenHead.setExpireDate(Long.parseLong(params.get("expire" ))); } catch (Exception e) { throw new IllegalArgumentException ("invalid_expire_date_format" ); } return tokenHead; } }
這也是我爲什麼說我這個 Jwt Token 不標準,因爲我是使用 UrlParam 而不是 Json 的形式存儲 Token 內容。標準的 Jwt Token 應該是使用 Json 來存儲。大概我這個叫 “UPwt” 會比較好(-w-||| )。。
UrlParams.class
這個 Utils 我就不列出來了,它的代碼在這裏:(UrlParams.java)[https://github.com/ShinonomeTN/snnmtn-tools/blob/master/snnmtn-utils/src/main/java/com/shinonometn/commons/tools/UrlParams.java]
判斷權限 判斷權限使用另外一個 Interceptor 就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 public class TokenSecurityInterceptor extends HandlerInterceptorAdapter { private final Logger logger = LoggerFactory.getLogger("security.securityInterceptor" ); public final static String ATTRIBUTE_ROLES = "security.user.roles" ; public final static String ATTRIBUTE_PERMISSIONS = "security.user.permissions" ; public TokenSecurityInterceptor () { } private Collection<String> getRequiredPermission (HandlerMethod handlerMethod) { RequiredTokenPermissions permissions = handlerMethod.getMethodAnnotation(RequiredTokenPermissions.class); if (permissions == null ) return Collections.emptyList(); return Arrays.asList(permissions.value()); } private Collection<String> getRequiredRoles (HandlerMethod handlerMethod) { RequiredTokenRole roles = handlerMethod.getMethodAnnotation(RequiredTokenRole.class); if (roles == null ) return Collections.emptyList(); return Arrays.asList(roles.value()); } @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { logger.info("Not handle class: {}, name: {}" , handler.getClass(), handler.toString() ); return true ; } HandlerMethod handlerMethod = (HandlerMethod) handler; Collection<String> requiredRoles = getRequiredRoles(handlerMethod); Collection<String> requiredPermissions = getRequiredPermission(handlerMethod); if (requiredRoles.isEmpty() && requiredPermissions.isEmpty()) { return true ; } JwtToken token = (JwtToken) request.getAttribute(SecurityTokenUtils.ATTRIBUTE_TOKEN); if (token == null ) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); return false ; } TokenHead tokenHead = (TokenHead) request.getAttribute(SecurityTokenUtils.ATTRIBUTE_TOKEN_HEAD); if (tokenHead == null ) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid_permission_info" ); return false ; } logger.debug("User [{}({})] invoked method [{}]." , tokenHead.getUserId(), tokenHead.getUsername(), handlerMethod ); Map<String, String> tokenPayload = UrlParams.parseParamMap( new String (Base64.getUrlDecoder().decode(token.getPayloadBase64())) ); Collection<String> roleList = UrlParams.parseList(tokenPayload.get("roles" )); Collection<String> permissionList = UrlParams.parseList(tokenPayload.get("permitted" )); if (!requiredPermissions.isEmpty()) { if (!permissionList.containsAll(requiredPermissions)) { logger.debug("User [{}({})] has no permission to invoke [{}]" , tokenHead.getUserId(), tokenHead.getUsername(), handlerMethod.toString() ); response.sendError(HttpServletResponse.SC_FORBIDDEN, "insufficient_permission" ); return false ; } } if (!requiredRoles.isEmpty()) { if (!roleList.containsAll(requiredRoles)) { logger.debug("User [{}({})] has not granted to invoke [{}]" , tokenHead.getUserId(), tokenHead.getUsername(), handlerMethod.toString() ); response.sendError(HttpServletResponse.SC_FORBIDDEN, "forbidden" ); return false ; } } if (!roleList.isEmpty()) { request.setAttribute(ATTRIBUTE_ROLES, roleList); } if (!permissionList.isEmpty()) { request.setAttribute(ATTRIBUTE_PERMISSIONS, permissionList); } return true ; } }
在這裏我利用兩個自定義的註解:
1 2 3 4 5 6 7 8 9 @Retention(RetentionPolicy.RUNTIME) public @interface RequiredTokenPermissions { String[] value(); } @Retention(RetentionPolicy.RUNTIME) public @interface RequiredTokenRole { String[] value(); }
在 Controller method 上寫的註解,通過 HandlerMethod.class
可以很輕易地獲取到。這樣在攔截器裏就可以獲取到對應 method 所需的權限。對比一下 token 上所描述的權限,就可以判斷是否允許當前請求訪問對應方法。
1 2 3 4 5 6 7 8 private Collection<String> getRequiredPermission (HandlerMethod handlerMethod) { RequiredTokenPermissions permissions = handlerMethod.getMethodAnnotation(RequiredTokenPermissions.class); if (permissions == null ) return Collections.emptyList(); return Arrays.asList(permissions.value()); }
Token 的內容結構,我設定爲兩個 list ,一個 roles 一個 permitted,分別對應角色和權限。所以在這裏解碼讀取:
1 2 3 4 5 6 Map<String, String> tokenPayload = UrlParams.parseParamMap( new String (Base64.getUrlDecoder().decode(token.getPayloadBase64())) ); Collection<String> roleList = UrlParams.parseList(tokenPayload.get("roles" )); Collection<String> permissionList = UrlParams.parseList(tokenPayload.get("permitted" ));
結構按照自己喜歡的來就好了,但我建議取短名的同時兼顧可讀性,在長度和可維護性上作點平衡。畢竟 base64 之後的字符串會增加約三分之一的體積,而且塞在頭部的數據太多讓請求的處理效率降低。
派發 Token 上面都是對 Token 解釋和判斷的實現,如果沒有 Token 它就沒什麼用。寫一個 Controller Method 來派發 Token 就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @RestController @RequestMapping("/token") public class TokenSignController { private final TokenSecurityConfiguration tokenSecurityConfiguration; public TokenSignController (TokenSecurityConfiguration tokenSecurityConfiguration) { this .tokenSecurityConfiguration = tokenSecurityConfiguration; } @PostMapping("/sign") public String signToken (@RequestParam("username") String username, @RequestParam("id") String userId, @RequestParam("password") String password) { if (!"123456" .equals(password)) { return "error:password" ; } TokenHead head = new TokenHead (); head.setUserId(userId); head.setUsername(username); head.setType("test" ); head.setExpireDate(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(60 )); String headParams = head.toUrlParams(); String content = UrlParams.encodePairs(Arrays.asList( pair("permitted" , "test" ), pair("roles" , "a,b,c,d,e" ), pair("special" , "something" ) )); JwtToken token = JwtToken.newToken( headParams.getBytes(), content.getBytes() ); token.sign(tokenSecurityConfiguration.getTokenSecret()); return token.toString(); } }
再寫點測試的方法來查看 Token 內容和驗證權限控制是否生效
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @RequiredTokenRole("a") @GetMapping("roles") public Object testRole ( @RequestAttribute(value = TokenSecurityInterceptor.ATTRIBUTE_ROLES, required = false) Collection<String> roles ) { if (roles != null ) { return roles; } return "no_roles" ; } @RequiredTokenPermissions("test") @GetMapping("permissions") public Object testPermissions ( @RequestAttribute(value = TokenSecurityInterceptor.ATTRIBUTE_PERMISSIONS, required = false) Collection<String> permissions ) { if (permissions != null ) { return permissions; } return "no_permissions" ; } @RequiredTokenRole("b") @RequiredTokenPermissions("test") @GetMapping("/all") public Object testAll ( @RequestAttribute(value = TokenSecurityInterceptor.ATTRIBUTE_PERMISSIONS) Collection<String> permissions, @RequestAttribute(value = TokenSecurityInterceptor.ATTRIBUTE_ROLES) Collection<String> roles ) { Map<String,Object> result = new HashMap <>(); result.put("roles" , roles); result.put("permission" , permissions); return result; }
就這樣就能實現 JwtToken 的權限鑑別了。
後話 Jwt 更多地只是個 “base64頭.base64內容.base64簽名” 的這麼一個格式。看官方的實現,我相信應該還有一些內容上的標準,就像 HTTP 只是一個簡單的基於文本的請求格式,但關於頭字段的命名和內容就有一大堆的規範。Jwt 作爲一個擺在頭的東西,是不適合存放大量內容的。我認爲 HTTP 頭存在的目的,是以輕量的數據來提示服務器和客戶端請求的方式和內容的格式。請求的內容可能會很大,但同時可能這次請求並不是一次應該處理的請求,所以設計成懶加載方式,避免在正常情況下過早地處理大量內容。
現在的 Web 越來越多的 Single Page Application,也越來越多的客戶端。Token 用作授權憑據的載體,越來越多地被用在 Web 上。我思考過服務器端會話(SESSION)的存在意義,觀察過 SESSION 的原理、Token 的大小和 Token 帶來的問題,我認爲服務器端會話的存在還是很有必要的。Session 通過一串數字標識一個在服務器上持久化存儲的內容,客戶端把這串數字存儲在 COOKIE 裏。每次請求只要知道 SESSION 的 ID, 服務器就知道當前用戶到底是誰。Session 在請求裏很輕量級,這樣避免每次請求的體積過大。但 Session 和 Token 都無法避免被盜用的問題。Session 的有效性是服務器端控制的,服務器只需要銷毀掉 Session 的內容,那麼被盜用的 Session 即會無效;而 Token 需要額外的措施,例如給予一個 ID 在請求的時候判斷,或者使用類似 Session 的機制。
我認爲其實這些更多地只是概念上的問題,實際上的實現還是數據的存儲交換和處理。這些概念的存在是爲了引導實現上的組合來達成一定的目的。過於實現爲上或概念爲上都不利於目的的實現。