基于Swagger实现接口混淆
前提
要做接口安全设计,保护后端接口安全的安全性,还可以针对接口的请求响应做改变,比如将原本的参数改为一个映射后的值,请求参数先去解析再转成我们的对象,保证我们的接口的含义不能被外部轻易理解,进而维护接口的安全。
既然是基于swagger的自动化混淆,为什么要这么做呢?因为你开发的接口要给人对接,那么对于前端来说,就需要看到正常的请求参数才可以,你加了混淆,没有通知到前端,那么请求参数肯定是不对的,所以就利用了swagger可以生成文档的特性来处理这个混淆的生成。
开发步骤
1.引入swagger的依赖
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<!--此版本支持单接口导出到postman,方便测试-->
<version>2.0.7</version>
</dependency>
2.添加混淆开关配置
#接口文档
swagger:
enabled: true
pathMapping: /
version: 1.0.0
# 混淆开关
confusion:
switch: false
#拦截的不加密的url
filter-encrypt-urls: /v2/api-docs,/swagger,/doc.html,/webjars,/error,/favicon.ico
3.添加swagger配置类
@Configuration
@EnableKnife4j
@EnableSwagger2WebMvc
public class SwaggerConfig {
@Value("${swagger.enabled}")
private boolean enabled;
@Value("${swagger.pathMapping}")
private String pathMapping;
@Value("${swagger.version}")
private String version;
/**
* 创建API
*/
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
// 是否启用Swagger
.enable(enabled)
.apiInfo(apiInfo())
.select()
// 扫描所有有注解的api,用这种方式更灵活,也可以通过扫描包路径的方式
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.pathMapping(pathMapping)
.globalOperationParameters(setHeaderParam());
}
/**
* 设置公共请求头
*
* @return
*/
private List<Parameter> setHeaderParam() {
List<Parameter> pars = new ArrayList<>();
ParameterBuilder parameterBuilder = new ParameterBuilder();
Parameter token = parameterBuilder.name(Constants.TOKEN).description("登录token")
.modelRef(new ModelRef("string")).parameterType("header")
.required(false).defaultValue("").build();
Parameter appVer = parameterBuilder.name(Constants.APP_VERSION).description("APP版本号")
.modelRef(new ModelRef("string")).parameterType("header")
.required(true).defaultValue("").build();
Parameter clientType = parameterBuilder.name(Constants.CLIENT_TYPE).description("客户端类型 安卓=android 苹果=ios")
.modelRef(new ModelRef("string")).parameterType("header")
.required(true).defaultValue("").build();
pars.add(token);
pars.add(appVer);
pars.add(clientType);
return pars;
}
/**
* 全局说明
* @return
*/
private static String desc(){
return "<div>"+"请求说明: 请求参数类型 分为 header body query. " +
"header 类型数据放到请求头中." +
" query中参数为 content-type为application/x-www-form-urlencoded." +
" body 中参数为 content-type为content-type为application/json 注意:(body中传的json数据是:直接传body类型对象参数下级目录中的参数作为json key传递。body类型参数对象本身不需要作为json key传递)" + "</div>";
}
/**
* 添加摘要信息
*/
private ApiInfo apiInfo()
{
// 用ApiInfoBuilder进行定制
return new ApiInfoBuilder()
.title("App接口")
.description("app端接口文档说明:" + desc() )
.version("版本号:" + version)
.build();
}
}
4.添加url拦截器,重写url
@Slf4j
public class UrlMappingFilter extends GenericFilterBean {
// 去除swagger接口拦截
public static final String FILTER_URL = "/v2/api-docs,/swagger,/doc.html,/webjars,/error,/favicon.ico";
@Override
public void doFilter(ServletRequest httpServletRequest, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) httpServletRequest;
// 去除前缀
String uri = request.getRequestURI();
String[] urlList = FILTER_URL.split(",");
for (String url : urlList) {
if (uri.contains(url)) {
chain.doFilter(httpServletRequest, response);
return;
}
}
ReqParamWrapper replaceRequest = new ReqParamWrapper(request);
chain.doFilter(replaceRequest, response);
}
}
5.重写请求参数
众所周知HttpServletRequest 对象没有提供任何的修改方法,所以对于其中的参数来说,只能是读取,不能更新,所以要使用ReqParamWrapper 来重写HttpServletRequest 对象的请求入参。
@Slf4j
public class ReqParamWrapper extends HttpServletRequestWrapper {
/**
* 重新赋值 request中的parameterMap中数据
*/
private Map<String, String[]> params = new HashMap<>();
private HttpServletRequest httpServletRequest;
/**
* 重新赋值request中的文件名称数据
*/
private Collection<Part> parts = new ArrayList<>();
@Override
public Collection<Part> getParts() {
return parts;
}
public ReqParamWrapper(HttpServletRequest request) {
super(request);
this.httpServletRequest = request;
//解密参数名称
this.decryptParamName(request);
//解密文件参数名称
this.decryptFileName(request);
}
@Override
public String getRequestURI() {
return SimpleEncryption.decryptUrl(httpServletRequest.getRequestURI());
}
@Override
public StringBuffer getRequestURL() {
StringBuffer url = new StringBuffer();
String scheme = httpServletRequest.getScheme();
int port = httpServletRequest.getServerPort();
if (port < 0) {
// Work around java.net.URL bug
port = 80;
}
url.append(scheme);
url.append("://");
url.append(httpServletRequest.getServerName());
if ((scheme.equals("http") && (port != 80))
|| (scheme.equals("https") && (port != 443))) {
url.append(':');
url.append(port);
}
url.append(getRequestURI());
return url;
}
/**
* 解密参数名称
*
* @param request
*/
private void decryptParamName(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
parameterMap.forEach((k, v) -> {
this.params.put(SimpleEncryption.decrypt(k, Constants.CONFUSION_PARAMETER), v);
});
}
/**
* 文件类型数据,解密文件参数名称
*/
private void decryptFileName(HttpServletRequest request) {
try {
if((null == request.getContentType()) || (!request.getContentType().toLowerCase(Locale.ENGLISH).startsWith(FileUploadBase.MULTIPART))){
return;
}
Collection<Part> oldParts = request.getParts();
if (!CollectionUtils.isEmpty(oldParts)) {
for (Part part : oldParts) {
Object fileItem = ReflectUtil.getFieldValue(part, "fileItem");
Object fieldNameValue = ReflectUtil.getFieldValue(fileItem, "fieldName");
if (fieldNameValue == null) {
continue;
}
ReflectUtil.setFieldValue(fileItem, "fieldName", SimpleEncryption.decrypt((String) fieldNameValue, Constants.CONFUSION_PARAMETER));
parts.add(part);
}
}
} catch (Exception e) {
log.error("解密文件名失败", e);
}
}
public ReqParamWrapper(HttpServletRequest request, Map<String, Object> extraParams) {
this(request);
addParameters(extraParams);
}
public void addParameters(Map<String, Object> extraParams) {
for (Map.Entry<String, Object> entry : extraParams.entrySet()) {
addParameter(entry.getKey(), entry.getValue());
}
}
@Override
public Enumeration<String> getParameterNames() {
Set<String> set = params.keySet();
if (set.size() > 0) {
return Collections.enumeration(set);
} else {
return super.getParameterNames();
}
}
@Override
public String getParameter(String name) {
String[] values = params.get(name);
if (values == null || values.length == 0) {
return null;
}
return values[0];
}
@Override
public String[] getParameterValues(String name) {
return params.get(name);
}
public void addParameter(String name, Object value) {
if (value != null) {
if (value instanceof String[]) {
params.put(name, (String[]) value);
} else if (value instanceof String) {
params.put(name, new String[]{(String) value});
} else {
params.put(name, new String[]{String.valueOf(value)});
}
}
}
}
7.注册拦截器
我们使用的是Ruoyi框架,所以基于Spring security,只需要注册我们的拦截器就可以了
// 2、spring security修改加密url,新增swagger过滤器
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> {
if (confusionSwitch) {
String decrypt = SimpleEncryption.encryptUrl(url);
registry.antMatchers(decrypt).permitAll();
} else {
registry.antMatchers(url).permitAll();
}
}
);
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers(confusionSwitch ? SimpleEncryption.encryptUrl("/api/user/register") : "/api/user/register").anonymous()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/webjars/**", "/*/api-docs", "/druid/**").permitAll()
.antMatchers("/swagger-resources/configuration/ui", // 用来获取支持的动作
"/swagger-resources", // 用来获取api-docs的URI
"/swagger-resources/configuration/security", // 安全选项
"/swagger-resources/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/signOut").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, AppJwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
if (confusionSwitch) {
httpSecurity.addFilterBefore(new UrlMappingFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
8. 重写请求体参数
@RestControllerAdvice
@Slf4j
public class EncryptResponseBodyAdapter implements ResponseBodyAdvice<Object> {
@Value("${swagger.confusion.switch:true}")
private Boolean confusionSwitch;
/**
* 该方法用于判断当前请求的返回值,是否要执行beforeBodyWrite方法
*
* @param methodParameter handler方法的参数对象
* @param converterType 将会使用到的Http消息转换器类类型
* @return 返回true则会执行beforeBodyWrite
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
/**
* 在Http消息转换器执转换,之前执行
*
* @param body 服务端的响应数据
* @param methodParameter handler方法的参数对象
* @param mediaType 响应的ContentType
* @param converterType 将会使用到的Http消息转换器类类型
* @param serverHttpRequest serverHttpRequest
* @param serverHttpResponse serverHttpResponse
* @return 返回 一个自定义的HttpInputMessage,可以为null,表示没有任何响应
*/
@Override
@Nullable
public Object beforeBodyWrite(@Nullable Object body, MethodParameter methodParameter, MediaType mediaType,
Class<? extends HttpMessageConverter<?>> converterType,
ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
String requestURI = ServletUtils.getRequest().getRequestURI();
log.info("处理请求地址-响应:{}", requestURI);
//获取请求数据
String srcData = JSON.toJSONString(body);
if (!requestURI.contains("v2/api-docs")) {
log.info("响应body={}", srcData);
}
if (!confusionSwitch) {
stopWatch.stop();
log.info("加密响应体耗时:【{}】ms", stopWatch.getLastTaskTimeMillis());
return body;
}
Object obj = changeJsonBody(body, requestURI);
stopWatch.stop();
log.info("加密响应体耗时:【{}】ms", stopWatch.getLastTaskTimeMillis());
return obj;
}
/**
*
* 对json数据key 进行混淆加密
*
*/
public Object changeJsonBody(Object body, String requestURI) {
if (Objects.isNull(body)) {
return null;
}
if (filterEncryptUrl(requestURI)) {
return body;
}
JSONObject jsonObject = JSON.parseObject(JSON.toJSONString(body, JSONWriter.Feature.WriteMapNullValue));
Object o = jsonObject.get("data");
if (o instanceof CharSequence || o instanceof Boolean || o instanceof Number || Objects.isNull(o)) {
return jsonObject;
}
String result = SimpleEncryption.handleEncryptReplaceStr(JSON.toJSONString(o, JSONWriter.Feature.WriteMapNullValue));
if (JSONUtil.isJson(result)) {
if (JSONUtil.isJsonArray(result)) {
JSONArray objects = JSON.parseArray(result);
log.info("返回加密后新参数body:{}", objects);
jsonObject.put("data", objects);
}
if (JSONUtil.isJsonObj(result)) {
JSONObject object = JSON.parseObject(result);
log.info("返回加密后新参数body:{}", object);
jsonObject.put("data", object);
}
} else {
jsonObject.put("data", result);
}
return jsonObject;
}
/**
* 过滤路径
*/
private static boolean filterEncryptUrl(String uri) {
String filterEncryptUrls = GlobalConfig.getConfig("filter-encrypt-urls");
String[] filterEncryptUrlStr = filterEncryptUrls.split(",");
for (String filterEncryptUrl : filterEncryptUrlStr) {
if (uri.contains(filterEncryptUrl)) {
return true;
}
}
return false;
}
}
9.重写响应体参数
/**
* <p>
* 接口入参解密
* </p>
* RequestBodyAdvice可以理解为在@RequestBody之前需要进行的 操作,<br/>
* ResponseBodyAdvice可以理解为在@ResponseBody之后进行的操作;<br/>
* 所以当接口需要加解密时,在使用@RequestBody接收前台参数之前可以先在RequestBodyAdvice的实现类中进行参数的解密,<br/>
* 当操作结束需要返回数据时,可以在@ResponseBody之后进入ResponseBodyAdvice的实现类中进行参数的加密。<br/>
*/
@RestControllerAdvice
@Slf4j
public class DecryptRequestBodyAdapter extends RequestBodyAdviceAdapter {
@Value("${swagger.confusion.switch:true}")
private Boolean confusionSwitch;
/**
* 该方法用于判断当前请求,是否要执行beforeBodyRead方法
*
* @param methodParameter handler方法的参数对象
* @param targetType handler方法的参数类型
* @param converterType 将会使用到的Http消息转换器类类型
* @return 返回true则会执行beforeBodyRead
*/
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 开关判断
return true;
}
/**
* 在Http消息转换器执转换,之前执行
*
* @param inputMessage 客户端的请求数据
* @param methodParameter handler方法的参数对象
* @param targetType handler方法的参数类型
* @param converterType 将会使用到的Http消息转换器类类型
* @return 返回 一个自定义的HttpInputMessage
*/
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter methodParameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
String requestURI = ServletUtils.getRequest().getRequestURI();
log.info("处理请求地址-请求:{}", requestURI);
// 获取请求数据
if (!confusionSwitch) {
//解密混淆
return inputMessage;
}
// 读取加密的请求体
InputStream body = inputMessage.getBody();
HttpHeaders headers = inputMessage.getHeaders();
String s = getBodyString(body);
if (!ObjectUtils.isEmpty(body)) {
body.close();
}
headers.remove("Content-Length");
log.info("解密前-body:{}", s);
if (Strings.isNotEmpty(s)) {
String bodyDec = SimpleEncryption.handleDecryptReplaceStr(s);
log.info("解密后-body:{}", bodyDec);
if (Strings.isNotEmpty(bodyDec)) {
// 使用解密后的数据,构造新的读取流
InputStream inputStream = new ByteArrayInputStream(
changeJsonBody(bodyDec, requestURI).getBytes(StandardCharsets.UTF_8));
return new HttpInputMessage() {
@Override
public HttpHeaders getHeaders() {
return inputMessage.getHeaders();
}
@Override
public InputStream getBody() {
return inputStream;
}
};
}
}
return inputMessage;
}
private String getBodyString(InputStream inputStream) {
ByteArrayOutputStream result = null;
try {
result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) {
result.write(buffer, 0, length);
}
return result.toString("UTF-8");
} catch (Exception e) {
log.error("getBodyString", e);
} finally {
if (result != null) {
try {
result.close();
} catch (IOException e) {
log.error("getBodyString close", e);
}
}
}
return "";
}
/**
*
* 对json数据key进行替换前m后n处理
*
*/
public String changeJsonBody(String body, String requestURI) {
CommonParam commonParam = ServletUtils.getCommonParam();
String path = commonParam.getPath();
if (StringUtils.isEmpty(path)) {
return body;
}
return body;
}
}
10.附件工具类
public class SuperJsonUtil {
/**
* 加密json key
*
* @param jsonObject json对象
* @return {@link JSONObject}
*/
private static JSONObject encryptKeys(JSONObject jsonObject) {
JSONObject outputJson = new JSONObject();
for (String key : jsonObject.keySet()) {
Object value = jsonObject.get(key);
String newKey = SimpleEncryption.encrypt(key, Constants.CONFUSION_PARAMETER); // the logic of new key
if (value instanceof JSONObject) {
outputJson.put(newKey, encryptKeys((JSONObject) value));
} else if (value instanceof JSONArray) {
outputJson.put(newKey, encryptKeysInArray((JSONArray) value));
} else {
outputJson.put(newKey, value);
}
}
return outputJson;
}
/**
* 加密json数组 key
*
* @param jsonArray json数组
* @return {@link JSONArray}
*/
private static JSONArray encryptKeysInArray(JSONArray jsonArray) {
JSONArray outputArray = new JSONArray();
for (int i = 0; i < jsonArray.size(); i++) {
Object item = jsonArray.get(i);
if (item instanceof JSONObject) {
outputArray.add(encryptKeys((JSONObject) item));
} else {
outputArray.add(item);
}
}
return outputArray;
}
/**
* 解密json的key
*
* @param jsonObject json对象
* @return {@link JSONObject}
*/
private static JSONObject decryptKeys(JSONObject jsonObject) {
JSONObject outputJson = new JSONObject();
for (String key : jsonObject.keySet()) {
Object value = jsonObject.get(key);
String newKey = SimpleEncryption.decrypt(key, Constants.CONFUSION_PARAMETER); // the logic of new key
if (value instanceof JSONObject) {
outputJson.put(newKey, decryptKeys((JSONObject) value));
} else if (value instanceof JSONArray) {
outputJson.put(newKey, decryptKeysInArray((JSONArray) value));
} else {
outputJson.put(newKey, value);
}
}
return outputJson;
}
/**
* 解密json数组的key
*
* @param jsonArray json数组
* @return {@link JSONArray}
*/
private static JSONArray decryptKeysInArray(JSONArray jsonArray) {
JSONArray outputArray = new JSONArray();
for (int i = 0; i < jsonArray.size(); i++) {
Object item = jsonArray.get(i);
if (item instanceof JSONObject) {
outputArray.add(decryptKeys((JSONObject) item));
} else {
outputArray.add(item);
}
}
return outputArray;
}
public static String handleDecryptReplaceStr(String json) {
if (StringUtils.isEmpty(json)) {
return null;
}
if (!JSONUtil.isJson(json)) {
return json;
}
if (JSONUtil.isJsonObj(json)) {
return decryptKeys(JSON.parseObject(json)).toString();
}
if (JSONUtil.isJsonArray(json)) {
return decryptKeysInArray(JSON.parseArray(json)).toString();
}
return json;
}
public static String handleEncryptReplaceStr(String json) {
if (StringUtils.isEmpty(json)) {
return null;
}
if (!JSONUtil.isJson(json)) {
return json;
}
if (JSONUtil.isJsonObj(json)) {
return encryptKeys(JSON.parseObject(json)).toString();
}
if (JSONUtil.isJsonArray(json)) {
return encryptKeysInArray(JSON.parseArray(json)).toString();
}
return json;
}
}