背景
在一些安全性要求较高的项目中,我们希望客户端请求数据可以做到数据加密,服务器端进行解密。单纯的 HTTPS 仍难以满足安全需要。
快速上手
application.properties配置文件
# 是否启用,默认false
security.api.encrypt.enabled=true
# 秘钥
security.api.encrypt.secret-key=自定义(16位)
# 是否开启全局加解密,默认false
security.api.encrypt.global-enabled=false
自定义加解密注解
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiSecret {
// 是否忽略加解密
boolean ignore() default false;
}
请求方式
普通表单
@PostMapping("/xxx")
public Result<String> form(@RequestParam(required = false) String param1,
@RequestParam(required = false) Integer param2) {
return new Result<String>().ok();
}
普通json body
@PostMapping("/xxx")
public Result<String> body(@RequestBody T t) {
return new Result<String>().ok();
}
实现原理
@Data
@Component
@ConfigurationProperties(ApiSecretProperties.PREFIX)
public class ApiSecretProperties {
/**
* 前缀
*/
public static final String PREFIX = "security.api.encrypt";
/**
* 固定参数名称
*/
public static final String PARAM_NAME = "encryption";
/**
* 是否启用标志,默认false
*/
private boolean enabled = false;
/**
* 是否全局加解密,默认false
*/
private boolean globalEnabled = false;
/**
* 秘钥
*/
private String secretKey;
}
public class ApiSecretSupports {
private static final Set<String> IGNORES = new HashSet<>();
static {
IGNORES.add("springfox.documentation.swagger.web.ApiResourceController");
IGNORES.add("springfox.documentation.swagger2.web.Swagger2Controller");
IGNORES.add("org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController");
}
public static boolean supports(MethodParameter parameter, ApiSecretProperties properties) {
if (IGNORES.contains(parameter.getDeclaringClass().getName())) {
return false;
}
return supports(parameter, properties.isGlobalEnabled());
}
private static boolean supports(MethodParameter parameter, boolean isGlobalEnabled) {
ApiSecret cApiSecret = parameter.getContainingClass().getAnnotation(ApiSecret.class);
if (cApiSecret == null) {
ApiSecret apiSecret = parameter.getMethodAnnotation(ApiSecret.class);
if (apiSecret == null) {
return isGlobalEnabled;
}
return !apiSecret.ignore();
}
if (cApiSecret.ignore()) {
return false;
}
ApiSecret apiSecret = parameter.getMethodAnnotation(ApiSecret.class);
if (apiSecret == null) {
return true;
}
return !apiSecret.ignore();
}
}
@Getter
@RequiredArgsConstructor
public class ApiSecretHttpInputMessage implements HttpInputMessage {
private final InputStream body;
private final HttpHeaders headers;
}
调整自定义参数解析器的优先级
@Component
@RequiredArgsConstructor
@ConditionalOnBean(ApiSecretRequestParamResolver.class)
public class ApiSecretResolverBeanPostProcessor implements BeanPostProcessor {
private final ApiSecretRequestParamResolver requestParamResolver;
private static final String HANDLER_ADAPTER = "requestMappingHandlerAdapter";
@Override
public Object postProcessAfterInitialization(@Nullable Object bean, @Nullable String beanName) throws BeansException {
if (HANDLER_ADAPTER.equals(beanName)) {
RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean;
if (adapter != null) {
List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<>();
argumentResolvers.add(requestParamResolver);
if (CollectionUtils.isNotEmpty(adapter.getArgumentResolvers())) {
argumentResolvers.addAll(adapter.getArgumentResolvers());
}
adapter.setArgumentResolvers(argumentResolvers);
}
}
return bean;
}
}
@RequestParam参数解密
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(value = ApiSecretProperties.PREFIX + ".enabled", havingValue = "true")
@ConditionalOnExpression("!'${security.api.encrypt.secret-key}'.isEmpty()")
public class ApiSecretRequestParamResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
private final ApiSecretProperties properties;
@Override
public boolean supportsParameter(MethodParameter parameter) {
Method method = parameter.getMethod();
if (!precondition(method)) {
return false;
}
return ApiSecretSupports.supports(parameter, properties);
}
@Override
@SneakyThrows
@SuppressWarnings("unchecked")
public Object resolveArgument(MethodParameter methodParameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) {
Parameter parameter = methodParameter.getParameter();
String params = webRequest.getParameter(ApiSecretProperties.PARAM_NAME);
if (StringUtils.isBlank(params)) {
return convertIfNecessary(methodParameter, webRequest, binderFactory);
}
AES aes = new AES(Mode.CBC, Padding.ZeroPadding, new SecretKeySpec(properties.getSecretKey().getBytes(), "AES"),
new IvParameterSpec(properties.getSecretKey().getBytes()));
try {
params = aes.decryptStr(params);
} catch (Exception e) {
log.error(e.getMessage());
throw new BusinessException("Please check if the encryption parameter value has been encrypted for transmission");
}
Map<String, Object> map = objectMapper.readValue(params, Map.class);
Object o = map.get(parameter.getName());
if (o == null) {
return convertIfNecessary(methodParameter, webRequest, binderFactory);
}
return o;
}
private Object convertIfNecessary(MethodParameter methodParameter, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Object o = null;
Parameter parameter = methodParameter.getParameter();
RequestParam requestParam = parameter.getAnnotation(RequestParam.class);
String defaultValue = (ValueConstants.DEFAULT_NONE.equals(requestParam.defaultValue()) ? null : requestParam.defaultValue());
if (StringUtils.isNotBlank(defaultValue)) {
o = defaultValue;
} else if (requestParam.required()) {
throw new MissingServletRequestParameterException(parameter.getName(),
parameter.getType().getSimpleName(), false);
}
o = handleNullValue(parameter.getName(), o, parameter.getType());
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, null, parameter.getName());
try {
o = binder.convertIfNecessary(o, methodParameter.getParameterType(), methodParameter);
} catch (ConversionNotSupportedException ex) {
throw new MethodArgumentConversionNotSupportedException(o, ex.getRequiredType(),
parameter.getName(), methodParameter, ex.getCause());
} catch (TypeMismatchException ex) {
throw new MethodArgumentTypeMismatchException(o, ex.getRequiredType(),
parameter.getName(), methodParameter, ex.getCause());
}
if (o == null && defaultValue == null && requestParam.required()) {
throw new MissingServletRequestParameterException(parameter.getName(),
parameter.getType().getSimpleName(), true);
}
}
return o;
}
private Object handleNullValue(String name, @Nullable Object o, Class<?> paramType) {
if (o == null) {
if (Boolean.TYPE.equals(paramType)) {
return Boolean.FALSE;
}
else if (paramType.isPrimitive()) {
throw new IllegalStateException("Optional " + paramType.getSimpleName() + " parameter '" + name +
"' is present but cannot be translated into a null value due to being declared as a " +
"primitive type. Consider declaring it as object wrapper for the corresponding primitive type.");
}
}
return o;
}
private boolean precondition(Method method) {
List<Parameter> parameters = Arrays.stream(Objects.requireNonNull(method).getParameters()).filter(p -> !p.isAnnotationPresent(RequestParam.class))
.collect(Collectors.toList());
return !CollectionUtils.isNotEmpty(parameters);
}
}
@RequestBody参数解密
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
@ConditionalOnProperty(value = ApiSecretProperties.PREFIX + ".enabled", havingValue = "true")
@ConditionalOnExpression("!'${security.api.encrypt.secret-key}'.isEmpty()")
public class ApiSecretRequestBodyAdvice implements RequestBodyAdvice {
private final ObjectMapper objectMapper;
private final ApiSecretProperties properties;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return ApiSecretSupports.supports(methodParameter, properties);
}
@Override
@SneakyThrows
@SuppressWarnings("unchecked")
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
InputStream messageBody = inputMessage.getBody();
byte[] bodyByteArray = StreamUtils.copyToByteArray(messageBody);
Map<String, Object> dataMap = objectMapper.readValue(bodyByteArray, Map.class);
String content = (String) dataMap.get(ApiSecretProperties.PARAM_NAME);
Preconditions.checkArgument(StringUtils.isNotBlank(content), "The encryption parameter requires non empty values");
AES aes = new AES(Mode.CBC, Padding.ZeroPadding, new SecretKeySpec(properties.getSecretKey().getBytes(), "AES"),
new IvParameterSpec(properties.getSecretKey().getBytes()));
byte[] decryptedBody;
try {
decryptedBody = aes.decrypt(StrUtil.str(content.getBytes(StandardCharsets.UTF_8), Charset.defaultCharset()));
} catch (Exception e) {
log.error(e.getMessage());
throw new BusinessException("Please check if the encryption parameter value has been encrypted for transmission");
}
InputStream inputStream = new ByteArrayInputStream(decryptedBody);
return new ApiSecretHttpInputMessage(inputStream, inputMessage.getHeaders());
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
@Override
public Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
响应数据加密
@ControllerAdvice
@RequiredArgsConstructor
@ConditionalOnProperty(value = ApiSecretProperties.PREFIX + ".enabled", havingValue = "true")
@ConditionalOnExpression("!'${security.api.encrypt.secret-key}'.isEmpty()")
public class ApiSecretResponseBodyAdvice implements ResponseBodyAdvice<ResponseEntity<Object>> {
private final ObjectMapper objectMapper;
private final ApiSecretProperties properties;
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return ApiSecretSupports.supports(returnType, properties);
}
@Override
@SneakyThrows
public ResponseEntity<Object> beforeBodyWrite(@Nullable Result<Object> responseEntity, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (responseEntity == null || responseEntity.getData() == null) {
return responseEntity;
}
byte[] bytes = objectMapper.writeValueAsBytes(responseEntity.getData());
AES aes = new AES(Mode.CBC, Padding.ZeroPadding, new SecretKeySpec(properties.getSecretKey().getBytes(), "AES"),
new IvParameterSpec(properties.getSecretKey().getBytes()));
return responseEntity.data(aes.encryptBase64(bytes));
}
}
总结
接口加解密是确保数据安全的关键技术之一,其通过对传输中的数据进行加密来防止数据在传递过程中被拦截、篡改或伪造。但是,单靠加解密技术并不足以应对所有安全挑战。必须整合多种安全策略,建立一个全方位的安全防护体系,以确保接口的全面安全。