Spring Boot REST 風格 API 接口 JWT Token 認證(簡易版)




1 摘要

接口的權限認證能夠有效保護接口不被濫用。常用的REST風格接口權限認證方式有簽名校驗和基於Auth2.0的Token校驗方式。本文將介紹基於 JWT 實現的接口 Token 認證解決方案1.0。

2 需求分析

接口認證需求:

  • 1 能夠有選擇地過濾沒有權限(Token)的請求
  • 2 Token 具有時效性
  • 3 如果用戶連續操作,Token 能夠自動刷新(自動延長有效期,eg: 加入 Token 有效期爲 10 小時,若用戶在 10 小時以內有操作,則在時間到達 10 時,Token 能夠自動刷新,而不是失效,從而影響用戶體驗)

解決思路:

  • 1 使用過濾器,可針對需要的接口進行權限認證,對於不需要校驗的接口進行放行
  • 2 使用 JWT 設置 Token 有效期
  • 3 用戶每次操作都返回最新的 Token ,用戶的新操作都基於上一次請求返回的 Token(Token 放在 Response Header 中,前端自行獲取)
  • 4 對 Token 設置刷新時間(刷新時間 < 有效時間),當 Token 的時間小於刷新時間時, Token 不用更新,可連續使用;當 Token 時間超過刷新時間,但是在有效時間以內時,刷新 Token ,刷新 Token 的有效期從刷新時間開始算起,往後延長有效時間,返回用戶最新 Token,當 Token 操作時間在有效時間以外時,提示 Token 失效。

3 核心依賴

../pom.xml
../demo-common/pom.xml
            <!-- JWT -->
            <dependency>
                <groupId>com.auth0</groupId>
                <artifactId>java-jwt</artifactId>
                <version>${auth.jwt.version}</version>
            </dependency>

其中 ${auth.jwt.version} 版本爲 3.8.3

4 核心代碼

4.1 JWT Token 生成與校驗工具類

../demo-common/src/main/java/com/ljq/demo/springboot/common/util/JwtUtil.java
package com.ljq.demo.springboot.common.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTDecodeException;

import java.io.Serializable;
import java.util.Date;

/**
 * @Description: JWT 工具類
 * @Author: junqiang.lu
 * @Date: 2019/12/3
 */
public class JwtUtil implements Serializable {

    private static final long serialVersionUID = -9101115541530000111L;

    /**
     * 默認祕鑰
     */
    private final static String DEFAULT_SECRET = "TS2XIUYNOKJZZDXD8YA9JJH5PM1IAHXPYCX7Q3JO";

    private JwtUtil(){

    }

    /**
     * 加密
     *
     * @param key
     * @param value
     * @return
     * @throws JWTCreationException
     */
    public static String encode(String key, String value) throws Exception {
        return encode(key, value, 0);
    }

    /**
     * 加密
     *
     * @param key
     * @param value
     * @param expTime
     * @return
     * @throws JWTCreationException
     */
    public static String encode(String key, String value, long expTime) throws Exception {
        return encode(null, key, value, expTime);
    }

    /**
     * 加密
     *
     * @param secret
     * @param key
     * @param value
     * @param expMillis
     * @return
     */
    public static String encode(String secret, String key, String value, long expMillis) throws Exception {
        if (secret == null || secret.length() < 1) {
            secret = DEFAULT_SECRET;
        }
        Date expDate = null;
        if (expMillis > 1) {
            expDate = new Date(System.currentTimeMillis() + expMillis);
        }
        Algorithm algorithm = Algorithm.HMAC256(secret);
        String token = JWT.create()
                .withIssuer("auth0")
                .withClaim(key,value)
                .withExpiresAt(expDate)
                .sign(algorithm);
        return token;
    }

    /**
     * 解密
     *
     * @param key
     * @param encodedToken
     * @return
     * @throws JWTDecodeException
     */
    public static String decode(String key, String encodedToken) throws Exception {
        return decode(null, key, encodedToken);
    }

    /**
     * 解密
     *
     * @param secret
     * @param key
     * @param encodedToken
     * @return
     */
    public static String decode(String secret, String key, String encodedToken) throws Exception {
        if (secret == null || secret.length() < 1) {
            secret = DEFAULT_SECRET;
        }
        Algorithm algorithm = Algorithm.HMAC256(secret);
        JWTVerifier verifier = JWT.require(algorithm)
                .withIssuer("auth0")
                .build();
        return verifier.verify(encodedToken).getClaim(key).asString();
    }


}

4.2 Token 攔截器

../demo-web/src/main/java/com/ljq/demo/springboot/web/acpect/SimpleCORSFilter.java
package com.ljq.demo.springboot.web.acpect;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ljq.demo.springboot.common.api.ApiResult;
import com.ljq.demo.springboot.common.api.ResponseCode;
import com.ljq.demo.springboot.common.constant.TokenConst;
import com.ljq.demo.springboot.common.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.Date;
import java.util.Objects;

/**
 * @Description: 跨域請求攔截器(簡易版)
 * @Author: junqiang.lu
 * @Date: 2019/5/21
 */
@Slf4j
public class SimpleCORSFilter implements Filter {

    /**
     * 不需要 Token 校驗的接口
     */
    private final static String[] NO_TOKEN_API_PATHS ={
            "/api/rest/user/save",
            "/api/rest/user/info"
    };

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        // *表示允許所有域名跨域
        httpResponse.addHeader("Access-Control-Allow-Origin", "*");
        httpResponse.addHeader("Access-Control-Allow-Headers","*");
        // 允許跨域的Http方法
        httpResponse.addHeader("Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,TRACE");
        // 允許瀏覽器訪問 Token 認證響應頭
        httpResponse.addHeader("Access-Control-Expose-Headers", TokenConst.TOKEN_HEADERS_FIELD);
        // 默認返回原 Token
        httpResponse.setHeader(TokenConst.TOKEN_HEADERS_FIELD, httpRequest.getHeader(TokenConst.TOKEN_HEADERS_FIELD));
        // 應對探針模式請求(OPTIONS)
        String methodOptions = "OPTIONS";
        if (httpRequest.getMethod().equals(methodOptions)) {
            httpResponse.setStatus(HttpServletResponse.SC_ACCEPTED);
            return;
        }
        /**
         * 校驗用戶 Token
         */
        boolean flag = !Arrays.asList(NO_TOKEN_API_PATHS).contains(httpRequest.getRequestURI());
        if (flag) {
            ResponseCode responseCode = checkToken(httpRequest, httpResponse);
            if (!Objects.equals(responseCode, ResponseCode.SUCCESS)) {
                log.warn("{}", responseCode);
                httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                httpResponse.setContentType("application/json; charset=utf-8");
                httpResponse.setCharacterEncoding("utf-8");
                PrintWriter writer = httpResponse.getWriter();
                writer.write(new ObjectMapper().writeValueAsString(ApiResult.failure(responseCode)));
                return;
            }
        }

        filterChain.doFilter(httpRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }

    /**
     * 用戶 Token 校驗
     *
     * @param request
     * @param response
     * @return
     */
    private ResponseCode checkToken(HttpServletRequest request, HttpServletResponse response) {
        try {
            String token = request.getHeader(TokenConst.TOKEN_HEADERS_FIELD);
            if (token == null || token.length() < 1) {
                return ResponseCode.USER_TOKEN_NULL_ERROR;
            }
            String tokenValue = JwtUtil.decode(TokenConst.TOKEN_KEY, token);
            long time = Long.parseLong(tokenValue.substring(tokenValue.indexOf("@") + 1));
            String userPhone = tokenValue.substring(0, tokenValue.indexOf("@"));
            log.info("{}, date: {}, userPhone: {}", tokenValue, new Date(time), userPhone);
            // 校驗 Token 有效性
            long subResult = System.currentTimeMillis() - time;
            if (subResult >= TokenConst.TOKEN_EXPIRE_TIME_MILLIS) {
                return ResponseCode.USER_TOKEN_ERROR;
            }
            if (subResult < TokenConst.TOKEN_REFRESH_TIME_MILLIS) {
                return ResponseCode.SUCCESS;
            }
            // 刷新 Token
            String newToken = JwtUtil.encode(TokenConst.TOKEN_KEY,userPhone + "@" + System.currentTimeMillis());
            response.setHeader(TokenConst.TOKEN_HEADERS_FIELD, newToken);
        } catch (Exception e) {
            log.warn("Token 校驗失敗,{}:{}", e.getClass().getName(), e.getMessage());
            return ResponseCode.USER_TOKEN_ERROR;
        }
        return ResponseCode.SUCCESS;
    }
}

其中 NO_TOKEN_API_PATHS 爲一些不需要 Token 校驗的接口

4.3 Token 相關的常量

../demo-common/src/main/java/com/ljq/demo/springboot/common/constant/TokenConst.java
package com.ljq.demo.springboot.common.constant;

/**
 * @Description: Token 相關常量
 * @Author: junqiang.lu
 * @Date: 2019/12/3
 */
public class TokenConst {


    /**
     * Token headers 字段
     */
    public static final String TOKEN_HEADERS_FIELD = "Authorization";
    /**
     * token key
     */
    public static final String TOKEN_KEY = "tokenPhone";
    /**
     * Token 刷新時間(單位: 毫秒)
     */
    public static final long TOKEN_REFRESH_TIME_MILLIS = 1000 * 60 * 60 * 2L;
    /**
     * token 有效期(單位: 毫秒)
     */
    public static final long TOKEN_EXPIRE_TIME_MILLIS = 1000 * 60 * 60 * 24 * 30L;


}

4.4 其他相關類

../demo-common/src/main/java/com/ljq/demo/springboot/common/api/ResponseCode.java

5 測試

Token 生成,參考:

com.ljq.demo.springboot.common.util.JwtUtil#encode(java.lang.String, java.lang.String)

5.1 不需要 Token

請求接口:

/api/rest/user/save

請求參數&返回結果:

在這裏插入圖片描述

5.2 需要 Token,請求成功

請求接口:

/api/rest/user/save2

請求參數&返回結果:

在這裏插入圖片描述

5.3 需要 Token,請求失敗

請求接口:

/api/rest/user/save2

請求參數與返回結果:

在這裏插入圖片描述

6 參考資料推薦

SpringBoot系列 - 集成JWT實現接口權限認證

JWT(JSON Web Token)自動延長到期時間

基於無狀態的token刷新機制

Response返回JSON數據

7 Github 源碼

Gtihub 源碼地址 : https://github.com/Flying9001/springBootDemo

個人公衆號:404Code,分享半個互聯網人的技術與思考,感興趣的可以關注.
404Code

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