Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

本篇所使用的雖然不是標準的 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;
}

// .... other methods
}

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 {

// 這裏我用一個 Configuration Bean 存儲一些需要用到的東西
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");

// 當沒有 Token 的時候,就不處理了。
if(StringUtils.isEmpty(header)) return true;

// secret 存儲在外部了
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));
}

/*
*
* Constructors
*
* */

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;
}

/*
*
* Getters....
*
* */
}

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;

//
// Getter setters....
//

/*
*
* */

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 {

// 如果不是 HandlerMethod 就不處理了
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);

// 如果 Controller Method 沒有打註解或者沒有寫權限和角色要求,則直接讓其通過
if (requiredRoles.isEmpty() && requiredPermissions.isEmpty()) {
// Api no role or permission required
return true;
}

// 沒有 Token 很明顯不給過
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()))
);

// Token 的結構後面再說
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);
// 如果方法沒打註解,只會拿到 null
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";
}

// 構造一個 Token 頭信息
TokenHead head = new TokenHead();
// 假裝我有個用戶 Id 和用戶名
head.setUserId(userId);
head.setUsername(username);
// 這個字段隨意
head.setType("test");
// 弄個過期時間,這樣 Token 就能自己過期了
head.setExpireDate(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(60));

// 構造點 Token 內容,塞點權限信息和其他信息
String headParams = head.toUrlParams();
String content = UrlParams.encodePairs(Arrays.asList(
pair("permitted", "test"),
pair("roles", "a,b,c,d,e"),
pair("special", "something")
));

// 然後構造個 Token ,把內容塞進去
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 的機制。

我認爲其實這些更多地只是概念上的問題,實際上的實現還是數據的存儲交換和處理。這些概念的存在是爲了引導實現上的組合來達成一定的目的。過於實現爲上或概念爲上都不利於目的的實現。

评论