一、業務背景
調用第三方介面或者前後端分離開發,調用業務介面時為防止刷機/違規調用等現象 需要加驗簽機制,比如支付類等介面,介面雙方為了確保資料參數在傳輸過程中未經過篡改,都需要對介面資料進行加簽,然後在介面伺服器端對介面參數進行驗簽,確保兩個簽名是一樣的,驗簽通過之後再進行商務邏輯處理。
二、處理思路
簽名驗簽的大方向無非就是:用戶端與服務端約定好,按照統一演算法,統一參數,統一順序,統一密鑰 進行加密,然後作對比;我這裡就介紹一種比較簡單實用且常用的簽名/驗簽方式思路:
用戶端:
簽名通過header傳到服務端,key名為:token
簽名:API版本號碼+AK+簽名到期時間+密鑰(SK)+URL+requestMethod+用戶端已排序的參數 做加密;
密碼編譯演算法:MD5;
密鑰(SK): 前後端統一,簽名加密的時候使用相當於加鹽;
AK : 多用戶端使用不同的SK, 服務端就需要通過AK找到對應的SK進行加密驗簽;
簽名到期時間:用戶端定義的簽名到期時間,超過該時間該簽章要求不可通過;
請求參數:已排序的參數通過request-line 或 request-body傳到服務端
服務端:
同樣的方式進行簽名,驗證。 以確認調用者是否合法,這就是介面簽名驗證的思路。
伺服器端代碼如下(僅供參考): 通過spring攔截器HandlerInterceptor(其內部將request請求body參數轉為line參數,So,直接getParameter)
/** * @Description:驗簽攔截 * @Author pengju * @date 2018/4/11 13:44 */public class ApiAkSkInterceptor implements HandlerInterceptor { private final Logger logger = LoggerFactory.getLogger(ApiAkSkInterceptor.class); @Autowired private BaseOpenApiConfig baseOpenApiConfig; /** * Intercept the execution of a handler. Called after HandlerMapping determined * an appropriate handler object, but before HandlerAdapter invokes the handler. * <p>DispatcherServlet processes a handler in an execution chain, consisting * of any number of interceptors, with the handler itself at the end. * With this method, each interceptor can decide to abort the execution chain, * typically sending a HTTP error or writing a custom response. * <p><strong>Note:</strong> special considerations apply for asynchronous * request processing. For more details see * {@link AsyncHandlerInterceptor}. * * @param request current HTTP request * @param response current HTTP response * @param handler chosen handler to execute, for type and/or instance evaluation * @return {@code true} if the execution chain should proceed with the * next interceptor or the handler itself. Else, DispatcherServlet assumes * that this interceptor has already dealt with the response itself. * @throws Exception in case of errors */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler == null || !(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; if (handlerMethod.getBeanType().isAnnotationPresent(ApiAkSkNotValidator.class) || handlerMethod.getMethod().isAnnotationPresent(ApiAkSkNotValidator.class)) { return true; } final long bt = System.currentTimeMillis(); response.addHeader(HTTP.CONTENT_TYPE, "application/json;charset=UTF-8"); try { // get header token String requestUri = request.getRequestURI(); final String requestMethod = request.getMethod(); final String token = request.getHeader(TokenHelper.X_IBEE_AUTH_TOKEN); logger.info("checkApiAkSkToken: check token,request uri={},method={},header token={}", requestUri, requestMethod, token); // token if (StringUtils.isBlank(token) || token.split("-").length != 4) { logger.error("checkApiAkSkToken: token invalid,head key={},token={}", TokenHelper.X_IBEE_AUTH_TOKEN, token); ResultBean result = new ResultBean(CommonEnum.ResponseEnum.AKSK_ERROR.getCode(), CommonEnum.ResponseEnum.AKSK_ERROR.getMsg(), null); return BaseOpenApiConfig.ajaxTimeOut(result, response); } // check ak value final String ak = token.split("-")[1]; if (StringUtils.isBlank(ak)) { logger.error("checkApiAkSkToken: ak is blank,token={}", token); ResultBean result = new ResultBean(CommonEnum.ResponseEnum.AKSK_ERROR.getCode(), CommonEnum.ResponseEnum.AKSK_ERROR.getMsg(), null); return BaseOpenApiConfig.ajaxTimeOut(result, response); } // get request body String jsonReqBody = parameter2Json(request); final String askReqBody = BaseOpenApiConfig.BLANK_JSON.equals(jsonReqBody) ? null : jsonReqBody; final String queryStr = request.getQueryString(); // check sk value final String sk = baseOpenApiConfig.getSkValue(ak); if (StringUtils.isBlank(sk)) { logger.error("checkApiAkSkToken: sk is blank,ak={}", ak); ResultBean result = new ResultBean(CommonEnum.ResponseEnum.AKSK_ERROR.getCode(), CommonEnum.ResponseEnum.AKSK_ERROR.getMsg(), null); return BaseOpenApiConfig.ajaxTimeOut(result, response); } // do check if (!new TokenHelper(ak, sk).verifyToken(token, requestUri, requestMethod, queryStr, askReqBody)) { logger.error("checkApiAkSkToken: token invalid,do not access,uri={},token={}", requestUri, token); ResultBean result = new ResultBean(CommonEnum.ResponseEnum.AKSK_ERROR.getCode(), CommonEnum.ResponseEnum.AKSK_ERROR.getMsg(), null); return BaseOpenApiConfig.ajaxTimeOut(result, response); } // set response header response.addHeader(TokenHelper.X_IBEE_AUTH_TOKEN, token); return true; } catch (Exception e) { logger.error("checkApiAkSkToken: catch e.", e); ResultBean result = new ResultBean(CommonEnum.ResponseEnum.AKSK_ERROR.getCode(), CommonEnum.ResponseEnum.AKSK_ERROR.getMsg(), null); return BaseOpenApiConfig.ajaxTimeOut(result, response); } finally { logger.debug("checkApiAkSkToken: sn={},end,use time={}ms.", bt, (System.currentTimeMillis() - bt)); } } /** * Intercept the execution of a handler. Called after HandlerAdapter actually * invoked the handler, but before the DispatcherServlet renders the view. * Can expose additional model objects to the view via the given ModelAndView. * <p>DispatcherServlet processes a handler in an execution chain, consisting * of any number of interceptors, with the handler itself at the end. * With this method, each interceptor can post-process an execution, * getting applied in inverse order of the execution chain. * <p><strong>Note:</strong> special considerations apply for asynchronous * request processing. For more details see * {@link AsyncHandlerInterceptor}. * * @param request current HTTP request * @param response current HTTP response * @param handler handler (or {@link HandlerMethod}) that started asynchronous * execution, for type and/or instance examination * @param modelAndView the {@code ModelAndView} that the handler returned * (can also be {@code null}) * @throws Exception in case of errors */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } /** * Callback after completion of request processing, that is, after rendering * the view. Will be called on any outcome of handler execution, thus allows * for proper resource cleanup. * <p>Note: Will only be called if this interceptor's {@code preHandle} * method has successfully completed and returned {@code true}! * <p>As with the {@code postHandle} method, the method will be invoked on each * interceptor in the chain in reverse order, so the first interceptor will be * the last to be invoked. * <p><strong>Note:</strong> special considerations apply for asynchronous * request processing. For more details see * {@link AsyncHandlerInterceptor}. * * @param request current HTTP request * @param response current HTTP response * @param handler handler (or {@link HandlerMethod}) that started asynchronous * execution, for type and/or instance examination * @param ex exception thrown on handler execution, if any * @throws Exception in case of errors */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } /** * 複製一份request map */ @SuppressWarnings("unchecked") public static String parameter2Json(HttpServletRequest request) { LinkedHashMap<String, String> linkMap = new LinkedHashMap<>(); Enumeration<String> enu = request.getParameterNames(); while (enu.hasMoreElements()) { String paraName = enu.nextElement(); String value = request.getParameter(paraName); linkMap.put(paraName, value); System.out.println(paraName + ":" + value); } return JSONObject.toJSONString(linkMap); }}
AKSKConfig:
/** * @Description:AKSKConfig * @Author pengju * @date 2018/4/11 13:32 */@Componentpublic class BaseOpenApiConfig { private static final Logger logger = LoggerFactory.getLogger(BaseOpenApiConfig.class); @Value("${api.ak.sk}") private String KEY_API_AK_SK; public final static String BLANK_JSON = "{}"; public final static Map<String, String> akSkMap = new ConcurrentHashMap<>(); @PostConstruct public void initAKSKMap() { String[] akSksArray = KEY_API_AK_SK.split("#"); if (ArrayUtils.isEmpty(akSksArray)) { logger.error("initAKSKMap: akSksArray is empty"); } for (String akSk : akSksArray) { String[] akSkArray = akSk.split(":"); if (ArrayUtils.isEmpty(akSkArray) && akSkArray.length < 2) { logger.error("initAKSKMap: akSkArray is error"); continue; } String localAk = akSkArray[0]; String localSk = akSkArray[1]; akSkMap.put(localAk, localSk); } } public static String streamToStr(final boolean closeReader, BufferedReader reader) throws IOException { if (reader == null) { return null; } String inLine; String str = ""; try { while ((inLine = reader.readLine()) != null) { str += inLine; } return str; } finally { if (closeReader) { reader.close(); } } } public String getSkValue(final String ak) throws Exception { if (StringUtils.isBlank(ak)) { logger.error("getSkValue: ak is blank"); return null; } return akSkMap.get(ak); } public static String getSignErrorMsg(final ReloadableResourceBundleMessageSource messageSource, String errorKey) { if (messageSource != null) { Locale locale = LocaleContextHolder.getLocale(); if (locale == null) { locale = Locale.SIMPLIFIED_CHINESE; } return messageSource.getMessage(errorKey, null, locale); } return "error msg"; } public static boolean ajaxTimeOut(ResultBean result, HttpServletResponse response) { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); response.setHeader("Pragma", "No-cache"); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Cache-Control", "no-store"); response.setDateHeader("Expires", 0); String json = JsonUtil.objectToJson(result); response.setHeader("X-Response-Json", json); logger.info("------------ajaxTimeOutResponse: logs--------------"); logger.info(json); return false; }}
簽名:
/** * @Description:簽名工具 * @Author pengju * @date 2018/4/11 13:34 */public class TokenHelper { private static final Logger logger = LoggerFactory.getLogger(TokenHelper.class); private String accessKey; private String secretKey; private static final String TOKEN_VERSION = "v2"; public static final String X_IBEE_AUTH_TOKEN = "X-IbeeAuth-Token"; private final List<String> allowedMethods = Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD"); public TokenHelper() { } public TokenHelper(String ak, String sk) { this.accessKey = ak; this.secretKey = sk; } /** * generate the token according the request or response contents * * @param urlPath the url of request * @param method request method, must be one of 'GET', 'POST', 'DELETE', 'HEAD', * 'PUT' * @param queryParam the query string of request * @param body the post body for request, or response body * @param expireTime the token expired time * @return the token */ public String generateToken(String urlPath, String method, String queryParam, String body, int expireTime) { if (accessKey == null || accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { throw new IllegalArgumentException("Invalid AK or SK"); } if (urlPath == null || urlPath.isEmpty()) { throw new IllegalArgumentException("Empty url path"); } if (!allowedMethods.contains(method)) { throw new IllegalArgumentException("invalid request method"); } String token; try { // |v2-{AK}-{ExpireTime}|{SK}| StringBuffer sbSign = new StringBuffer(String.format("|%s-%s-%d|%s|", TOKEN_VERSION, accessKey, expireTime, secretKey)); // {UrlPath}| sbSign.append(decodeUtf8(urlPath)).append("|"); // {Method}| sbSign.append(method).append("|"); // {QueryParam}| if (StringUtils.isNoneBlank(queryParam)) { List<String> qsArray = new ArrayList<String>(); for (String kv : queryParam.split("&")) { String[] t = kv.split("="); if (t.length > 1) { qsArray.add(String.format("%s=%s", decodeUtf8(t[0]), decodeUtf8(t[1]))); } else { qsArray.add(String.format("%s=", decodeUtf8(t[0]))); } } Collections.sort(qsArray); boolean first = true; for (String s : qsArray) { if (first) { first = false; } else { sbSign.append("&"); } sbSign.append(s); } } sbSign.append("|"); // {body}| if (StringUtils.isNoneBlank(body)) { sbSign.append(body); } sbSign.append("|"); MessageDigest digest = MessageDigest.getInstance("MD5"); digest.reset(); digest.update(sbSign.toString().getBytes("UTF-8")); // v2-{AK}-{ExpireTime}-{Signature} token = String.format("%s-%s-%s-%s", TOKEN_VERSION, accessKey, expireTime, new String(Hex.encodeHex(digest.digest()))); } catch (Exception e) { logger.error("failed to decode url or query path,e.msg={}", e.getMessage()); throw new IllegalStateException("Bad encoded url path or query string"); } return token; } private static String decodeUtf8(String url) { try { return URLDecoder.decode(url, "UTF-8"); } catch (UnsupportedEncodingException var2) { return url; } } /** * verify the token * * @param token the token for verification * @param urlPath the url of request * @param method request method, must be one of 'GET', 'POST', 'DELETE', 'HEAD', * 'PUT' * @param queryParam the query string of request * @param body the post body for request, or response body */ public boolean verifyToken(String token, String urlPath, String method, String queryParam, String body) { if (StringUtils.isBlank(token)) { logger.warn("token is null"); return false; } try { String[] tokenParts = token.split("-"); if (tokenParts.length != 4) { logger.warn("invalid token format"); return false; } if (!TOKEN_VERSION.equals(tokenParts[0])) { logger.warn("invalid token protocol version"); return false; } int expireTime = Integer.parseInt(tokenParts[2]); if (expireTime < System.currentTimeMillis() / 1000) { logger.warn("expired token"); return false; } String tokenVerify = generateToken(urlPath, method, queryParam, body, expireTime); if (token.equals(tokenVerify)) { return true; } } catch (Exception e) { logger.error("failed to parse token '{}',e.msg={}", token, e.getMessage()); } return false; } public String getAccessKey() { return accessKey; } public void setAccessKey(String accessKey) { this.accessKey = accessKey; } public String getSecretKey() { return secretKey; } public void setSecretKey(String secretKey) { this.secretKey = secretKey; }}