likes
comments
collection
share

举一反三:Spring MVC密码传输通过注解自动解密

作者站长头像
站长
· 阅读数 72

近期项目需要对密码和身份证号码使用RSA进行前端加密传输,后端收到后需要对参数字段进行解密再校验。可以使用RequestBodyAdvice或自定义反序列化器达到该效果。其中自定义反序列化器效果更好,更加推荐。

项目概况

  • Java
  • Spring Boot

RequestBodyAdvice

RequestBodyAdvice是Spring MVC的自带的一个AOP接口,能够对带有RequestBody注解的参数在序列化前后进行增强。可以通过继承该接口的抽象类RequestBodyAdviceAdapter来实现在请求体的JSON反序列化为入参对象后,对入参对象带有某个注解的属性进行RSA解密,并修改该属性的值。该AOP是在javax.validation相关的参数校验之前的,不会影响原本的参数校验。

核心代码如下:

/**
 * 待解密请求体对象注解,仅可加在RequestBody上
 */
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Decrypted {
}
/**
 * 待解密属性注解,加上后该属性自动解密,仅可加在String类型的属性上;
 * 不对空白内容进行解密;解密失败会报错;解密后会修改属性值,优先于参数校验注解。
 * 之后还可以拓展该注解,可以指定解密的类型和字符串格式等
 */
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptedField {
}
/**
 * 入参请求体属性解密AOP
 */
@ControllerAdvice
@Slf4j
public class DecryptionRequestBodyAdvice extends RequestBodyAdviceAdapter {

    /**
     * 判断请求体是否需要进行解密:
     * 请求体的对象包含Decrypted注解时,请求体存在属性需要解密
     */
    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasParameterAnnotation(Decrypted.class);
    }

    /**
     * 对请求体对象中包含DecryptedField注解的属性进行解密
     */
    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
                                Class<? extends HttpMessageConverter<?>> converterType) {
        Field[] fields = ReflectUtil.getFields(body.getClass()); // 通过cn.hutool.core.util.ReflectUtil获取包括父类在内的各种修饰符修饰的属性

        // 查找属性,找到包含DecryptedField注解的String类型属性进行解密
        for (Field field : fields) {
            if (field.isAnnotationPresent(DecryptedField.class)) {
                DecryptedField decryptedField = field.getAnnotation(DecryptedField.class);
                if (field.getType() == String.class) {
                    try {
                        field.setAccessible(true);
                        String encryptedValue = (String) field.get(body);
                        // 空白字符串不进行解密
                        if (StringUtils.isBlank(encryptedValue)) {
                            continue;
                        }
                        String decryptedValue = RsaUtil.decrypt(encryptedValue);
                        field.set(body, decryptedValue);
                    } catch (CryptoException e) {
                        log.error("{} 的 {} 属性解密失败:", field.getDeclaringClass().getName(), field.getName(), e);
                        throw new BaseException("参数错误");
                    } catch (BaseException e) {
                        throw e;
                    } catch (Exception e) {
                        log.error("属性解密时发生错误:", e);
                        throw new BaseException("系统错误");
                    }
                }
            }
        }
        return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
    }

}
/**
* Controller的一个方法。为参数添加Decrypted注解,表示该入参对象存在需要解密的字段
*/
@PostMapping(value = "/save")
public Result<String> save(@RequestBody @Valid @Decrypted UserSaveREQ req) {
    return biz.save(req);
}
/**
* 入参对象
*/
@Data
public class UserSaveREQ implements Serializable {

    private static final long serialVersionUID = 1L;

    @NotBlank(message = "登录账号不能为空")
    private String loginName;

    @DecryptedField // 添加该注解表示需要解密的字段
    @NotBlank(message = "密码不能为空")
    private String loginPassword;
    
}

自定义反序列化器

Spring MVC默认的JSON反序列化器是Jackson库中的ObjectMapper。可以通过自定义一个反序列化器,为某些需要解密的字段在序列化时就直接进行解密。相比实现RequestBodyAdvice的方法,自定义反序列化器不需要在参数上添加注解,只需要在属性上添加一个注解即可;也不需要遍历字段,更加高效,是更推荐的方法。

核心代码如下:

/**
 * 待解密属性注解,加上后该属性自动解密,仅可加在String类型的属性上;
 * 不对空白内容进行解密;解密失败或时间戳超时会报错;优先于参数校验注解。
 * 之后还可以拓展该注解
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonDeserialize(using = DecryptionDeserializer.class)
public @interface DecryptedField {
}

/**
 * 入参属性解密反序列化器
 */
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
public class DecryptionDeserializer extends JsonDeserializer<String> implements ContextualDeserializer {

    private DecryptedField decryptedField; // 注解

    private String dtoClassName; // 入参对象名,用于打印日志

    @Override
    public String deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException {
        String encryptedValue = jsonParser.getText();
        // 空白字符串不进行解密
        if (StringUtils.isBlank(encryptedValue) || decryptedField == null) {
            return encryptedValue;
        }
        try {
            return RsaUtil.decrypt(encryptedValue);
        } catch (CryptoException e) {
            log.error("{} 解密失败:", dtoClassName, e);
            throw new BaseException("参数错误");
        } catch (Exception e) {
            log.error("属性解密时发生错误:", e);
            throw new BaseException("系统错误");
        }
    }

    /**
    * 通过实现ContextualDeserializer接口的方法来判断是否使用该反序列化器
    */
    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty beanProperty) {
        if (beanProperty != null && Objects.equals(beanProperty.getType().getRawClass(), String.class)) {
            DecryptedField annotation = beanProperty.getAnnotation(DecryptedField.class);
            String fullName = beanProperty.getMember().getFullName();

            if (annotation == null) {
                annotation = beanProperty.getContextAnnotation(DecryptedField.class);
            }
            if (annotation != null) {
                return new DecryptionDeserializer(annotation, fullName);
            }
        }
        return new DecryptionDeserializer();
    }
}
/**
* 入参对象
*/
@Data
public class UserSaveREQ implements Serializable {

    private static final long serialVersionUID = 1L;

    @NotBlank(message = "登录账号不能为空")
    private String loginName;

    @DecryptedField // 添加该注解表示需要解密的字段
    @NotBlank(message = "密码不能为空")
    private String loginPassword;
    
}

总结

根据对MVC流程的分析,可以在反序列化时解密,也可以也在反序列化后修改字段,这些方法都是优先于参数校验注解的。可以通过自定义注解对流程进行更加细致的自定义,如指定解密的类型、字符串格式、参数错误的提示等等。

与此同时也可以举一反三,MVC响应对象的加密处理等,也可以通过RequestBodyAdvice或自定义序列化器的形式实现。譬如以下场景:

  • 对身份证号码、手机号、姓名等敏感信息使用*隐藏一部分内容,或完全不显示,或显示成指定内容
  • 对LocalDateTime、LocalDate进行固定格式化(也可以通过配置实现)
  • 对于空数组或空列表,前端希望是一个空列表[]而不是null
  • 金额在后端存的是以分为单位,传给前端时要以元为单位
转载自:https://juejin.cn/post/7270695217228726324
评论
请登录