spring cloud進階七 [jwt服務間的鑑權]
一、為什麼要使用jwt?
在微服務架構下的服務基本都是無狀態的,傳統的使用session的方式不再適用,如果使用的話需要做同步session機制,所以產生了了一些技術來對微服務架構進行保護,例如常用的鑑權框架Spring Security OAuth2和用Jwt來進行保護,相對於框架而言,jwt較輕,且可以自包含一些使用者資訊和設定過期時間,省去了Spring Security OAuth2繁瑣的步驟。
二、什麼是JWT?
jwt(JSON WEB TOKEN)是一種用來在網路上宣告某種身份的令牌(TOKEN),它的特點是緊湊且自包含並且基於JSON,通過一些常用的演算法對包含的主體資訊進行加密,安全性很高。它通常有三個部分組成:頭資訊(Header),訊息體(Payload),簽名(Signature)。
Header通常用來宣告令牌的型別和使用的演算法,Payload主要用來包含使用者的一些資訊,Signature部分則是將Base64編碼後的Header和Payload進行簽名。
三、在Spring Cloud 下如何使用 jwt?
在SC(Spring Cloud簡稱,以下將都採用這種方式)下通常使用需要安全保護的有兩處,分別為系統認證和服務內部鑑權。
1系統認證
(1)基本流程
jwt基本使用方式如下圖
使用者在提交登入資訊後,伺服器校驗資料後將通過金鑰的方式來生成一個字串token返回給客戶端,客戶端在之後的請求會把token放在header裡,在請求到達伺服器後,伺服器會檢驗和解密token,如果token被篡改或者失效將會拒絕請求,如果有效則伺服器可以獲得使用者的相關資訊並執行請求內容,最後將結果返回。
在微服務架構下,通常有單獨一個服務Auth去管理相關認證,為了安全不會直接讓使用者訪問某個服務,會開放一個入口服務作為閘道器gateway,只允許外網閘道器,所有請求首先訪問gateway,有gateway將請求路由到各個服務,spring cloud下通常使用zuul來實現閘道器,整個基本過程如下圖所示
客戶端請求閘道器後,閘道器會根據路徑過濾請求,是登入獲取token操作的路徑則直接放行,請求直接到達auth服務進行登入操作,之後進行JWT私鑰加密生成token返回給客戶端;是其他請求將會進行token私鑰解密校驗,如果token被篡改或者失效則直接拒絕訪問並返回錯誤資訊,如果驗證成功經過路由到達請求服務,請求服務響應並返回資料。
(2)如何實現登入、重新整理、登出等?
登入比較簡單,在驗證身份資訊後可以使用工具包例如jjwt根據使用者資訊生成token並設定有效時長,最後將token返回給客戶端儲存即可,客戶端只需要每次訪問時將token加在請求頭裡即可,然後在zuul增加一個filter,此filter來過濾請求,如果是登入獲取token則放行,其他的話用公鑰解密驗證token是否有效。
如果要實現重新整理,則需要在生成token時生成一個refreshKey,在登入時和token一併返回給客戶端,然後由客戶端儲存定時使用refreshKey和token來重新整理獲取新的有效時長的token,這個refreshKey可自定義生成,為了安全起見,伺服器可能需要快取refreshKey,可使用redis來進行儲存,每次重新整理token都將生成新的refreshKey和token,伺服器需要將老refreshKey替換,客戶端儲存新的token和refreshKey來進行之後的訪問和重新整理。
如果要實現登出,並使得舊的token即便在有效期內也不能通過驗證,則需要修改登入、重新整理、和優化zuul的filter。首先在登入時生成token和refreshKey後,需要將token也進行快取,如果通過redis進行快取可以直接放一個Set下,此Set儲存所有未過期的token。其次,在重新整理時在這個Set中刪除舊的token並放入新的。最後對zuulFilter進行優化,在解密時先從redis裡存放token的Set查詢此token是否存在(redis的Set有提供方法),如果沒有則直接拒絕,如果有再進行下一步解密驗證有效時長,驗證有效時長是為了防止重新整理機制失效、沒有重新整理機制、網路異常強行退出等事件出現,在這種情況下舊的token沒有被刪除,導致了舊的token一直可以訪問(如果只驗證是否token是否在快取中)。在登出時只需要刪除redis中Set的token記錄就好,最後寫個定時器去定時刪除redis中Set裡面過時的token,原因也是重新整理機制失效、沒有重新整理機制、網路異常強行退出等事件出現導致舊的token沒有被刪除。
四、JWT存在的問題
jwt第一次生成token 的時候會比較慢,而且因為採用了加密演算法保證安全,所以比較耗CPU,在高併發的情況下需要考慮CPU佔用問題。還有一個問題,jwt生成的token比較長,可能需要考慮流量問題。
五、程式碼示例
JwtUtil.java
@Component
public class JwtUtil {
private static UserRepository userRepository;
@Autowired
public JwtUtil(UserRepository userRepository) {
JwtUtil.userRepository = userRepository;
}
public static final long EXPIRATION_TIME = 3600_000_000L; // 1000 hour
static final String SECRET = "ThisIsASecret";
static final String TOKEN_PREFIX = "Bearer";
static final String HEADER_STRING = "Authorization";
public static String generateToken(String username,Date generateTime) {
HashMap<String, Object> map = new HashMap<>();
//可以把任何安全的資料放到map裡面
map.put("username", username);
map.put("generateTime",generateTime);
String jwt = Jwts.builder()
.setClaims(map)
.setExpiration(new Date(generateTime.getTime() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
return jwt;
}
/**
* @param token
* @return
*/
public static Map<String,Object> validateToken(String token) {
Map<String,Object> resp = new HashMap<String,Object>();
if (token != null) {
// 解析token
try {
Map<String, Object> body = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
.getBody();
String username = (String) (body.get("username"));
Date generateTime = new Date((Long)body.get("generateTime"));
if(username == null || username.isEmpty()){
resp.put("ERR_MSG",Constants.ERR_MSG_USERNAME_EMPTY);
return resp;
}
//賬號在別處登入
if(userRepository.findByUsername(username).getLastLoginTime().after(generateTime)){
resp.put("ERR_MSG",Constants.ERR_MSG_LOGIN_DOU);
return resp;
}
resp.put("username",username);
resp.put("generateTime",generateTime);
return resp;
}catch (SignatureException | MalformedJwtException e) {
// TODO: handle exception
// don't trust the JWT!
// jwt 解析錯誤
resp.put("ERR_MSG",Constants.ERR_MSG_TOKEN_ERR);
return resp;
} catch (ExpiredJwtException e) {
// TODO: handle exception
// jwt 已經過期,在設定jwt的時候如果設定了過期時間,這裡會自動判斷jwt是否已經過期,如果過期則會丟擲這個異常,我們可以抓住這個異常並作相關處理。
resp.put("ERR_MSG",Constants.ERR_MSG_TOKEN_EXP);
return resp;
}
}else {
resp.put("ERR_MSG",Constants.ERR_MSG_TOKEN_EMPTY);
return resp;
}
}
}
UserController.java
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserRepository userRepository;
//註冊或登入
@RequestMapping("/login")
@Transactional
public UserResponse login(User user){
String username = user.getUsername();
String password = user.getPassword();
//TODO 檢驗引數的完整性
UserResponse userResponse = new UserResponse();
User tUser = userRepository.findByUsername(username);
//檢驗username是否存在
user.setLastLoginTime(new Date());
if(tUser!=null){
//檢驗密碼是否正確
if(!tUser.getPassword().equals(password)) {
userResponse.setErrorNum(Constants.ERR_NUM_PWD_ERR);
userResponse.setErrorMsg(Constants.ERR_MSG_PWD_ERR);
return userResponse;
}
userRepository.updateLastLoginTimeByUserName(user.getLastLoginTime(),username);
}else {
try {
tUser = userRepository.save(user);
} catch (Exception e) {
userResponse.setErrorNum(Constants.ERR_NUM_SERVER_ERR);
userResponse.setErrorMsg(Constants.ERR_MSG_SERVER_ERR);
return userResponse;
}
}
userResponse.setErrorNum(Constants.ERR_NUM_OK);
userResponse.setErrorMsg(Constants.ERR_MSG_OK);
userResponse.setUserName(username);
userResponse.setUserId(tUser.getId());
userResponse.setToken(JwtUtil.generateToken(username,user.getLastLoginTime()));
return userResponse;
}
}
HelloController.java
@RestController
public class HelloController {
@RequestMapping("/hello")
public Map login(HttpServletRequest request){
String token = request.getParameter("token");
return JwtUtil.validateToken(token);
}
}