JWT的使用:Spring Cloud微服務接口鑑權

0 JWT是什麼

JWT(JSON Web Token)是一種開放標準,它以一種緊湊且獨立的方式,可以在各方之間作爲JSON對象安全地傳輸信息。
其認證原理是,客戶端向服務器申請授權,服務器認證以後,生成一個token字符串並返回給客戶端,此後客戶端在請求受保護的資源時攜帶這個token,服務端進行驗證再從這個token中解析出用戶的身份信息。

0.1 JWT的結構

一個JWT是一個字符串,其由Header(頭部)、Payload(負載)和Signature(簽名)三個部分組成,中間以.號分隔,其格式爲Header.Payload.Signature,如下所示:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJNSU5HIiwiZXhwIjoxNTQ3MzQ1MjgxLCJ1c2VyTmFtZSI6ImFkbWluIiwiaWF0IjoxNTQ3MzQ1MjIxfQ.
NfsnnoORftR4oP7hFHjmDgj5HYxKd-RXjEW9upn9Tgk

Header
Header本質是一個JSON對象,由包含了兩個屬性:

{
  "alg": "HS256",
  "typ": "JWT"
}

alg表示簽名的算法,typ則表示token的類型。然後,將Header進行Base64URL 編碼轉成字符串作爲JWT的第一組成部分。
Payload
JWT的第二組成部分被稱作載荷,其本質也是一個JSON對象,用來存放認證所需的一些額外聲明,其包含有一種類型:標準中註冊的聲明(registered),公共的聲明(public), 和私有的聲明(private claims),其中公有聲明和私有聲明可自定義字段。
標準中註冊的聲明 (建議但不強制使用) :

iss (issuer):簽發人
exp (expiration time):過期時間
sub (subject):主題
aud (audience):受衆
nbf (Not Before):生效時間
iat (Issued At):簽發時間
jti (JWT ID):編號

僅有的聲明和私有的聲明可以添加一些額外的用戶屬性,例如:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

由於JWT 是不加密的,所以不應該把用戶敏感類信息放在這個部分。
Payload部分進行Base64URL 編碼轉成字符串,即爲JWT的第二組成部分。

Signature
JWT的第三組成部分是簽名,它是對前面兩個部分的簽名。比如,如果JWT中Header指定的算法是 MAC SHA256,那麼Signature爲,其中secret加解密使用的密鑰:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

關於JWT更詳細的介紹,可到官網查看:https://jwt.io/introduction/

0.2 JWT的使用場景

由於JWT傳輸過程中可攜帶額外的信息(如用戶信息),使用JWT後,服務端不再需要保存用戶的狀態信息,服務端需要保存的只有加解密使用的密鑰。由於JWT的無狀態特性(不需存儲),在一個分佈式的面向服務的框架中,使用JWT做接口鑑權是再適合不過的了。

1 JWT用作接口鑑權

由於JWT的天然無狀態,在JWT生成的一刻,其過期時間就已經註定了,並且不可更改,所以,如果要對JWT實現token續簽的話,一般的做法是額外生成一個refreshToken用於獲取新token,refreshToken需存儲於服務端,其過期時間可以比JWT的過期時間要稍長。
在Spring Cloud微服務框架下,要實現接口的鑑權,最好的方式就是通過Spring Cloud Gateway網關服務來完成。網關提供了統一服務的訪問接口,客戶端在調用服務時,須先經過網關,網關進行權限的校驗,對非法請求進行過濾後再進行請求的轉發,以此來完成鑑權。
接下來,我們通過在網關中使用JWT實現一個簡單的鑑權服務。

其授權基本流程:
1 用戶提交賬號密碼,服務生成token和refreshToken並返回,其中refreshToken設置過期時間,並關聯用戶身份和當前的token;
2 用戶請求服務時,前置網關對請求進行過濾,從請求中取出token,在驗證通過後將token中包含的身份信息作爲參數所加到當前請求;
3 用戶請求到達具體的服務後,取出包含的身份信息,進行具體的業務處理;

token的刷新流程:
1 用戶攜帶refreshToken參數請求token刷新接口,服務端在判斷refreshToken未過期後,取出關聯的用戶信息和當前token,使用當前用戶信息重新生成token,並將舊的token置於黑名單中,返回新的token;
2 用戶攜帶新token重新進行請求;

該示例中JWT的實現使用了開源的Java JWT,地址爲:https://github.com/auth0/java-jwt

1.1 代碼實現

1.1.1 登錄授權:token的生成

	public Map<String,Object> login(@RequestParam String userName,
                                    @RequestParam String password){
        Map<String,Object> resultMap = new HashMap<>();
        //賬號密碼校驗
        if(StringUtils.equals(userName, "admin")&&
                StringUtils.equals(password, "admin")){

            //生成JWT
            String token = buildJWT(userName);
            //生成refreshToken
            String refreshToken = UUID.randomUUID().toString().replaceAll("-","");
            //保存refreshToken至redis,使用hash結構保存使用中的token以及用戶標識
            String refreshTokenKey = String.format(jwtRefreshTokenKeyFormat, refreshToken);
            stringRedisTemplate.opsForHash().put(refreshTokenKey,
                    "token", token);
            stringRedisTemplate.opsForHash().put(refreshTokenKey,
                    "userName", userName);
            //refreshToken設置過期時間
            stringRedisTemplate.expire(refreshTokenKey,
                    refreshTokenExpireTime, TimeUnit.MILLISECONDS);
            //返回結果
            Map<String, Object> dataMap = new HashMap<>();
            dataMap.put("token", token);
            dataMap.put("refreshToken", refreshToken);
            resultMap.put("code", "10000");
            resultMap.put("data", dataMap);
            return resultMap;
        }
        resultMap.put("isSuccess", false);
        return resultMap;
    }
    private String buildJWT(String userName){
        //生成jwt
        Date now = new Date();
        Algorithm algo = Algorithm.HMAC256(secretKey);
        String token = JWT.create()
                .withIssuer("MING")
                .withIssuedAt(now)
                .withExpiresAt(new Date(now.getTime() + tokenExpireTime))
                .withClaim("userName", userName)//保存身份標識
                .sign(algo);
        return token;
    }

1.1.2 token的刷新

public Map<String,Object> refreshToken(@RequestParam String refreshToken){
        Map<String,Object> resultMap = new HashMap<>();
        String refreshTokenKey = String.format(jwtRefreshTokenKeyFormat, refreshToken);
        String userName = (String)stringRedisTemplate.opsForHash().get(refreshTokenKey,
                "userName");
        if(StringUtils.isBlank(userName)){
            resultMap.put("code", "10001");
            resultMap.put("msg", "refreshToken過期");
            return resultMap;
        }
        String newToken = buildJWT(userName);
        //替換當前token,並將舊token添加到黑名單
        String oldToken = (String)stringRedisTemplate.opsForHash().get(refreshTokenKey,
                "token");
        stringRedisTemplate.opsForHash().put(refreshTokenKey, "token", newToken);
        stringRedisTemplate.opsForValue().set(String.format(jwtBlacklistKeyFormat, oldToken), "",
                tokenExpireTime, TimeUnit.MILLISECONDS);
        resultMap.put("code", "10000");
        resultMap.put("data", newToken);
        return resultMap;
    }

1.1.3 網關鑑權:token的校驗

@Component
public class AuthFilter implements GlobalFilter, Ordered {

    private static final Logger LOGGER = LoggerFactory.getLogger(AuthFilter.class);

    @Value("${jwt.secret.key}")
    private String secretKey;

    @Value("${auth.skip.urls}")
    private String[] skipAuthUrls;

    @Value("${jwt.blacklist.key.format}")
    private String jwtBlacklistKeyFormat;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public int getOrder() {
        return -100;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String url = exchange.getRequest().getURI().getPath();
        //跳過不需要驗證的路徑
        if(Arrays.asList(skipAuthUrls).contains(url)){
            return chain.filter(exchange);
        }
        //從請求頭中取出token
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        //未攜帶token或token在黑名單內
        if (token == null ||
                token.isEmpty() ||
                    isBlackToken(token)) {
            ServerHttpResponse originalResponse = exchange.getResponse();
            originalResponse.setStatusCode(HttpStatus.OK);
            originalResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
            byte[] response = "{\"code\": \"401\",\"msg\": \"401 Unauthorized.\"}"
                    .getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = originalResponse.bufferFactory().wrap(response);
            return originalResponse.writeWith(Flux.just(buffer));
        }
        //取出token包含的身份,用於業務處理
        String userName = verifyJWT(token);
        if(userName.isEmpty()){
            ServerHttpResponse originalResponse = exchange.getResponse();
            originalResponse.setStatusCode(HttpStatus.OK);
            originalResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
            byte[] response = "{\"code\": \"10002\",\"msg\": \"invalid token.\"}"
                    .getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = originalResponse.bufferFactory().wrap(response);
            return originalResponse.writeWith(Flux.just(buffer));
        }
        //將現在的request,添加當前身份
        ServerHttpRequest mutableReq = exchange.getRequest().mutate().header("Authorization-UserName", userName).build();
        ServerWebExchange mutableExchange = exchange.mutate().request(mutableReq).build();
        return chain.filter(mutableExchange);
    }

    /**
     * JWT驗證
     * @param token
     * @return userName
     */
    private String verifyJWT(String token){
        String userName = "";
        try {
            Algorithm algorithm = Algorithm.HMAC256(secretKey);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer("MING")
                    .build();
            DecodedJWT jwt = verifier.verify(token);
            userName = jwt.getClaim("userName").asString();
        } catch (JWTVerificationException e){
            LOGGER.error(e.getMessage(), e);
            return "";
        }
        return userName;
    }

    /**
     * 判斷token是否在黑名單內
     * @param token
     * @return
     */
    private boolean isBlackToken(String token){
        assert token != null;
        return stringRedisTemplate.hasKey(String.format(jwtBlacklistKeyFormat, token));
    }
}

完整代碼見:GitHub

參考鏈接:
http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章