Spring Boot介面如何設計防篡改、防重放攻擊

雲天發表於2019-07-26

Spring Boot 防篡改、防重放攻擊

本示例主要內容

  • 請求引數防止篡改攻擊
  • 基於timestamp方案,防止重放攻擊
  • 使用swagger介面文件自動生成

API介面設計

API介面由於需要供第三方服務呼叫,所以必須暴露到外網,並提供了具體請求地址和請求引數,為了防止被別有用心之人獲取到真實請求引數後再次發起請求獲取資訊,需要採取很多安全機制。

  • 需要採用https方式對第三方提供介面,資料的加密傳輸會更安全,即便是被破解,也需要耗費更多時間
  • 需要有安全的後臺驗證機制,達到防引數篡改+防二次請求(本示例內容)

防止重放攻擊必須要保證請求只在限定的時間內有效,需要通過在請求體中攜帶當前請求的唯一標識,並且進行簽名防止被篡改,所以防止重放攻擊需要建立在防止簽名被串改的基礎之上

防止篡改

  • 客戶端使用約定好的祕鑰對傳輸引數進行加密,得到簽名值sign1,並且將簽名值存入headers,傳送請求給服務端
  • 服務端接收客戶端的請求,通過過濾器使用約定好的祕鑰對請求的引數(headers除外)再次進行簽名,得到簽名值sign2。
  • 服務端對比sign1和sign2的值,如果對比一致,認定為合法請求。如果對比不一致,說明引數被篡改,認定為非法請求

基於timestamp的方案,防止重放

每次HTTP請求,headers都需要加上timestamp引數,並且timestamp和請求的引數一起進行數字簽名。因為一次正常的HTTP請求,從發出到達伺服器一般都不會超過60s,所以伺服器收到HTTP請求之後,首先判斷時間戳引數與當前時間相比較,是否超過了60s,如果超過了則提示簽名過期(這個過期時間最好做成配置)。

一般情況下,黑客從抓包重放請求耗時遠遠超過了60s,所以此時請求中的timestamp引數已經失效了。
如果黑客修改timestamp引數為當前的時間戳,則sign引數對應的數字簽名就會失效,因為黑客不知道簽名祕鑰,沒有辦法生成新的數字簽名(前端一定要保護好祕鑰和加密演算法)。

相關核心思路程式碼

過濾器

@Slf4j
@Component
/**
 * 防篡改、防重放攻擊過濾器
 */
public class SignAuthFilter implements Filter {
    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void init(FilterConfig filterConfig) {
        log.info("初始化 SignAuthFilter");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 防止流讀取一次後就沒有了, 所以需要將流繼續寫出去
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletRequest requestWrapper = new RequestWrapper(httpRequest);

        Set<String> uriSet = new HashSet<>(securityProperties.getIgnoreSignUri());
        String requestUri = httpRequest.getRequestURI();
        boolean isMatch = false;
        for (String uri : uriSet) {
            isMatch = requestUri.contains(uri);
            if (isMatch) {
                break;
            }
        }
        log.info("當前請求的URI是==>{},isMatch==>{}", httpRequest.getRequestURI(), isMatch);
        if (isMatch) {
            filterChain.doFilter(requestWrapper, response);
            return;
        }

        String sign = requestWrapper.getHeader("Sign");
        Long timestamp = Convert.toLong(requestWrapper.getHeader("Timestamp"));

        if (StrUtil.isEmpty(sign)) {
            returnFail("簽名不允許為空", response);
            return;
        }

        if (timestamp == null) {
            returnFail("時間戳不允許為空", response);
            return;
        }

        //重放時間限制(單位分)
        Long difference = DateUtil.between(DateUtil.date(), DateUtil.date(timestamp * 1000), DateUnit.MINUTE);
        if (difference > securityProperties.getSignTimeout()) {
            returnFail("已過期的簽名", response);
            log.info("前端時間戳:{},服務端時間戳:{}", DateUtil.date(timestamp * 1000), DateUtil.date());
            return;
        }

        boolean accept = true;
        SortedMap<String, String> paramMap;
        switch (requestWrapper.getMethod()) {
            case "GET":
                paramMap = HttpUtil.getUrlParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, sign, timestamp);
                break;
            case "POST":
            case "PUT":
            case "DELETE":
                paramMap = HttpUtil.getBodyParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, sign, timestamp);
                break;
            default:
                accept = true;
                break;
        }
        if (accept) {
            filterChain.doFilter(requestWrapper, response);
        } else {
            returnFail("簽名驗證不通過", response);
        }
    }

    private void returnFail(String msg, ServletResponse response) throws IOException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        PrintWriter out = response.getWriter();
        String result = JSONObject.toJSONString(AjaxResult.fail(msg));
        out.println(result);
        out.flush();
        out.close();
    }

    @Override
    public void destroy() {
        log.info("銷燬 SignAuthFilter");
    }
}

簽名驗證

@Slf4j
public class SignUtil {

    /**
     * 驗證簽名
     *
     * @param params
     * @param sign
     * @return
     */
    public static boolean verifySign(SortedMap<String, String> params, String sign, Long timestamp) {
        String paramsJsonStr = "Timestamp" + timestamp + JSONObject.toJSONString(params);
        return verifySign(paramsJsonStr, sign);
    }

    /**
     * 驗證簽名
     *
     * @param params
     * @param sign
     * @return
     */
    public static boolean verifySign(String params, String sign) {
        log.info("Header Sign : {}", sign);
        if (StringUtils.isEmpty(params)) {
            return false;
        }
        log.info("Param : {}", params);
        String paramsSign = getParamsSign(params);
        log.info("Param Sign : {}", paramsSign);
        return sign.equals(paramsSign);
    }

    /**
     * @return 得到簽名
     */
    public static String getParamsSign(String params) {
        return DigestUtils.md5DigestAsHex(params.getBytes()).toUpperCase();
    }
}

不做簽名驗證的介面做成配置(application.yml)

spring:
  security:
    # 簽名驗證超時時間
    signTimeout: 300
    # 允許未簽名訪問的url地址
    ignoreSignUri:
      - /swagger-ui.html
      - /swagger-resources
      - /v2/api-docs
      - /webjars/springfox-swagger-ui
      - /csrf

屬性程式碼(SecurityProperties.java)

@Component
@ConfigurationProperties(prefix = "spring.security")
@Data
public class SecurityProperties {

    /**
     * 允許忽略簽名地址
     */
    List<String> ignoreSignUri;

    /**
     * 簽名超時時間(分)
     */
    Integer signTimeout;
}

簽名測試控制器

@RestController
@Slf4j
@RequestMapping("/sign")
@Api(value = "簽名controller", tags = {"簽名測試介面"})
public class SignController {

    @ApiOperation("get測試")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "username", value = "使用者名稱", required = true, dataType = "String"),
            @ApiImplicitParam(name = "password", value = "密碼", required = true, dataType = "String")
    })
    @GetMapping("/testGet")
    public AjaxResult testGet(String username, String password) {
        log.info("username:{},password:{}", username, password);
        return AjaxResult.success("GET引數檢驗成功");
    }

    @ApiOperation("post測試")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "data", value = "測試實體", required = true, dataType = "TestVo")
    })
    @PostMapping("/testPost")
    public AjaxResult<TestVo> testPost(@Valid @RequestBody TestVo data) {
        return AjaxResult.success("POST引數檢驗成功", data);
    }

    @ApiOperation("put測試")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "id", value = "編號", required = true, dataType = "Integer"),
            @ApiImplicitParam(name = "data", value = "測試實體", required = true, dataType = "TestVo")
    })
    @PutMapping("/testPut/{id}")
    public AjaxResult testPut(@PathVariable Integer id, @RequestBody TestVo data) {
        data.setId(id);
        return AjaxResult.success("PUT引數檢驗成功", data);
    }

    @ApiOperation("delete測試")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "idList", value = "編號列表", required = true, dataType = "List<Integer> ")
    })
    @DeleteMapping("/testDelete")
    public AjaxResult testDelete(@RequestBody List<Integer> idList) {
        return AjaxResult.success("DELETE引數檢驗成功", idList);
    }
}

前端js請求示例

var settings = {
  "async": true,
  "crossDomain": true,
  "url": "http://localhost:8080/sign/testGet?username=abc&password=123",
  "method": "GET",
  "headers": {
    "Sign": "46B1990701BCF090E3E6E517751DB02F",
    "Timestamp": "1564126422",
    "User-Agent": "PostmanRuntime/7.15.2",
    "Accept": "*/*",
    "Cache-Control": "no-cache",
    "Postman-Token": "a9d10ef5-283b-4ed3-8856-72d4589fb61d,6e7fa816-000a-4b29-9882-56d6ae0f33fb",
    "Host": "localhost:8080",
    "Cookie": "SESSION=OWYyYzFmMDMtODkyOC00NDg5LTk4ZTYtODNhYzcwYjQ5Zjg2",
    "Accept-Encoding": "gzip, deflate",
    "Connection": "keep-alive",
    "cache-control": "no-cache"
  }
}

$.ajax(settings).done(function (response) {
  console.log(response);
});

注意事項

  • 該示例沒有設定祕鑰,只做了引數升排然後建立md5簽名
  • 示例請求的引數md5原文字為:Timestamp1564126422{"password":"123","username":"abc"}
  • 注意headers請求頭帶上了Sign和Timestamp引數
  • js讀取的Timestamp必須要在服務端獲取
  • 該示例不包括分佈試環境下,多臺伺服器時間同步問題

自動生成介面文件

  • 配置程式碼
@Configuration
@EnableSwagger2
public class Swagger2Config {
    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.easy.sign"))
                .paths(PathSelectors.any())
                .build();
    }

    //構建 api文件的詳細資訊函式,注意這裡的註解引用的是哪個
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("簽名示例")
                .contact(new Contact("簽名示例網站", "http://www.baidu.com", "test@qq.com"))
                .version("1.0.0")
                .description("簽名示例介面描述")
                .build();
    }
}
  • 自動生成文件地址:http://localhost:8080/swagger-ui.html

資料

相關文章