美文网首页
保护你的递增数值类型ID

保护你的递增数值类型ID

作者: 若兮相言 | 来源:发表于2023-10-06 17:20 被阅读0次

    在数据库设计时,使用自增类型的数据库ID有一个缺点,那就是返回到前端后,容易被人猜解,例如有一个用户的主页的url为 /user/1,那将1自增就可以爬到系统所有的用户,有些场景中这样的风险是不被允许的,必须新增额外的ID字段来解决这个问题。本文则是提供另一种加密的思路来保护ID不被猜解。

    优点

    统一实现系统中所有自增数值类ID的加密保护
    灵活配置,插件式设计,用就打开,不用就关闭

    实现思路

    要保证不被猜解顺序的ID,一定是没有规律的,所以初步的加密算法可以是将Long类型的ID经过AES加密再由BASE64编码得到,这样的ID可解密,不具备枚举性,只要不泄露AES的密钥,基本是安全的。由于加密后的ID可能需要在url中传输,所以base64编码时要使用url安全的编码方式

    由于数据库层是bitint类型ID自增,所以只在controller入参和出参这一层做ID的转换即可。这一点需要根据自己使用的框架做适配。下面是具体实现,这里用到的技术是springmvc+springdata-jpa+querydsl+openapi3+modelmapper,这里只列出需要适配的一些技术栈,主要是view层数据到service层需要进行ID的解密,还有一些文档和实体转换工具的配置,具体咱往下看。

    ID加解密工具类

    /**
     * 提供ID和字符串的互相转换,避免数值型的ID返回给前端被猜测到
     */
    public final class IDCryptoUtil {
    
        private static final String ENCODING = "UTF-8";
    
        private static final byte[] ENCRYPT_KEY_BYTES = new byte[] {
                2, 48, -126, 1, 34,...
        };
    
        public static SecretKeySpec getEncryptionKey() {
            MessageDigest sha;
            try {
                sha = MessageDigest.getInstance("SHA-256");
                byte[] key = sha.digest(ENCRYPT_KEY_BYTES);
                key = Arrays.copyOf(key, 16);
                return new SecretKeySpec(key, "AES");
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    
        /**
         * 加密
         * @param message 待加密的消息,实际加密内容为message.toString()
         */
        public static String encrypt(Object message) {
            if(message == null) {
                throw new IllegalArgumentException("Only not-null values can be encrypted!");
            }
            try {
                Cipher cipher = getCipher();
                cipher.init(Cipher.ENCRYPT_MODE, getEncryptionKey());
                String messageValue = (message instanceof String) ?
                        (String) message :
                        String.valueOf(message);
                return Base64.getUrlEncoder().encodeToString(
                        cipher.doFinal(messageValue.getBytes(ENCODING))
                );
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    
        /**
         * 解密
         * @param message 待解密内容
         * @return 解密后的内容
         */
        public static String decrypt(String message) {
            try {
                Cipher cipher = getCipher();
                cipher.init(Cipher.DECRYPT_MODE, getEncryptionKey());
                return new String(cipher.doFinal(Base64.getUrlDecoder().decode(message)));
            } catch (Exception e) {
                throw new IllegalArgumentException(e);
            }
        }
    
        /**
         * 解密消息
         * @param message 待解密消息
         * @param clazz 解密后的类,需实现ValueOf(String)方法
         */
        public static <T> T decrypt(String message, Class<T> clazz) {
            try {
                Cipher cipher = getCipher();
                cipher.init(Cipher.DECRYPT_MODE, getEncryptionKey());
                String decryptedValue = new String(cipher.doFinal(Base64.getUrlDecoder().decode(message)));
                return ReflectionUtils.invokeStaticMethod(
                        ReflectionUtils.getMethodOrNull(clazz, "valueOf", String.class),
                        decryptedValue
                );
            } catch (Exception e) {
                throw new IllegalArgumentException(e);
            }
        }
    
        private static Cipher getCipher() {
            try {
                return Cipher.getInstance("AES/ECB/PKCS5PADDING");
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    
        public static void main(String[] args) {
            String encrypt = IDCryptoUtil.encrypt("1");
            System.out.println(encrypt);
            Long decrypt = IDCryptoUtil.decrypt(encrypt, Long.class);
            System.out.println(decrypt);
        }
    
    }
    
    

    自定义EncryptId类型,描述一个加密的ID,同时提供了ID的加解密,代码还没写注释,见名知意吧,很容易看得懂

    /**
     * 加密的ID,数据库使用bigint,返回给前端对应的字符串,使得无法猜解其他数据的ID
     */
    @Getter
    @NoArgsConstructor
    public class EncryptId implements Serializable {
    
        private Long id;
    
        public EncryptId(Long id) {
            this.id = id;
        }
    
        @JsonValue
        public String getEncryptId() {
            if (id == null) {
                return "";
            }
            if (!EncryptIdConfig.ENABLED) {
                return id.toString();
            }
            return IDCryptoUtil.encrypt(id);
        }
    
        public static EncryptId originValueOf(Long originId) {
            return new EncryptId(originId);
        }
    
        public static EncryptId encryptIdValueOf(String encryptId) {
            return new EncryptId(IDCryptoUtil.decrypt(encryptId, Long.class));
        }
    
        public static EncryptId valueOf(String encryptId) {
            //jackson, modelmapper等库会使用该方法进行构造ID对象
            return encryptIdValueOf(encryptId);
        }
    }
    

    出参DTO转换适配

    在BaseDTO中,使用这个ID,我这里所有用到ID的DTO都继承了BaseDTO,所以只改BaseDTO就可以了,Long改为EncryptId

    /**
     * DTO基类
     */
    @Getter
    @Setter
    public abstract class BaseDTO implements Serializable {
        protected EncryptId id;
        ...
    }
    

    我的项目中DTO和PO的转换都使用了modelmapper,所以要告诉modelmapper如何转换Long和EncryptId类型,如果你使用了其他的对象转换工具,也需要告诉他如何转换。有一个通用的做类型转换的工具很重要,不然需要每个地方去改造,有点成本太高了。

        static {
            ModelMapper modelMapper = ModelMapperUtil.getModelMapper();
            modelMapper.addConverter(new Converter<Long, EncryptId>() {
                @Override
                public EncryptId convert(MappingContext<Long, EncryptId> context) {
                    return EncryptId.originValueOf(context.getSource());
                }
            });
        }
    

    可以看到我并没有配置EncryptId到Long的转换,因为入参的转换我使用spring的转换系统(下面会说到),这个要看自己是使用谁转换的,灵活配置就行。

    PO中继续使用Long类型的ID

    /**
     * PO基类
     */
    @Getter
    @Setter
    @MappedSuperclass
    @EntityListeners({AuditingEntityListener.class})
    @FieldNameConstants
    @Audited
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    public abstract class BaseEntity implements Serializable, Persistable<Long> {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        protected Long id;
        ...
    }
    

    至此,系统中出参的地方基本改造完毕(如果你所有的DTO都继承BaseDTO的话),还有就是其他DTO里有一些单独的Long id,也需要更换为EncryptId类型,这个需要逐项检查了。

    入参DTO转换适配

    普通方式的入参都要经过spring的转换系统转换类型,所以在这里配置,我们分别配置了String转EncryptId,EncryptId转Long和String转Long,在String转Long这个转换器中,我们直接把String当做了加密ID来处理转换为long,这里多多少少是有点不合适的,但由于只是在WebConversionService中注册,这个转换服务将都用于web层的转换,而web层的装换在自定义转换器转换失败时会回退到默认的PropertyEditor来转换,所以即使接收普通的Long类型参数也是能接收的。而String转Long这个转换器也是spring-data-jpa中扩展功能,直接在controller接收PO参数时用到的,spring-data-jpa会先将数据转换为ID类型也就是long。当然更妥善的做法是禁用掉spring-data-jpa提供的功能自己实现,由于不是public类这里就不处理了。

    关于spring-提供的扩展功能这里有介绍
    https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#core.web.basic
    如果你没有用到这个功能,那就无需注册这个转换器

    @Configuration
    public class EncryptIdConfig implements WebMvcConfigurer {
    
        public static final boolean ENABLED = false;
        @Override
        public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
            resolvers.add(new EncryptIdHandlerMethodArgumentResolver());
        }
    
        @Override
        public void addFormatters(FormatterRegistry registry) {
            if (ENABLED) {
                registry.addConverter(new EncryptIdStrToLongConverter());
                registry.addConverter(new org.springframework.core.convert.converter.Converter<String, EncryptId>() {
                    @Override
                    public EncryptId convert(String source) {
                        return EncryptId.encryptIdValueOf(source);
                    }
                });
                registry.addConverter(new org.springframework.core.convert.converter.Converter<EncryptId, Long>() {
                    @Override
                    public Long convert(EncryptId source) {
                        return source.getId();
                    }
                });
            }
        }
    
    }
    /**
     * 只在webConversionService中使用, 用于直接接收po类型参数时对Id的转换,
     * 在controller中接收long类型参数时,这里转换失败,springmvc会回退到propertyEditor中进行转换
     * {org.springframework.beans.TypeConverterDelegate:132}
     */
    @Slf4j
    class EncryptIdStrToLongConverter implements GenericConverter {
        @Override
        public Set<ConvertiblePair> getConvertibleTypes() {
            return CollUtil.newHashSet(
                    new ConvertiblePair(String.class, Long.class)
            );
        }
    
        @Override
        public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
            String str = (String) source;
            try {
                if (EncryptIdConfig.ENABLED) {
                    return EncryptId.encryptIdValueOf(str).getId();
                } else {
                    return Long.valueOf(source.toString());
                }
            }catch (IllegalArgumentException e) {
                log.trace(e.toString());
                throw new ErrorMsgException("ID不合法");
            }
        }
    }
    
    

    对单独的EncryptId类型参数的接收支持

    /**
     * 接收EncryptId类型参数
     */
    @RequiredArgsConstructor
    public class EncryptIdHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
    
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.getParameterType().equals(EncryptId.class);
        }
    
        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                      NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
            if (parameter.getParameterName() == null) {
                return null;
            }
            String value = webRequest.getParameter(parameter.getParameterName());
            return EncryptId.encryptIdValueOf(value);
        }
    
    }
    
    

    对openapi3的支持

    主要是将ID类型由long改为string

    @Slf4j
    public class EncryptIdDomainClassGlobalSupport implements GlobalOperationCustomizer {
    
        @Override
        public Operation customize(Operation operation, HandlerMethod handlerMethod) {
            if (!EncryptIdConfig.ENABLED) {
                return operation;
            }
            if (operation.getParameters() == null) {
                return operation;
            }
            for (Parameter parameter : operation.getParameters()) {
                if (parameter.getExtensions() == null) {
                    continue;
                }
                Object o = parameter.getExtensions().get("x-is-domain-id");
                if (o != null && (Boolean) o) {
                    parameter.setSchema(new StringSchema());
                }
            }
            return operation;
        }
    
    }
    
    
    /**
     * spring-data-commons支持了直接在controller接收PO类参数,但是swagger不支持,这里做一个支持
     */
    @Slf4j
    @RequiredArgsConstructor
    public class DomainClassGlobalSupport implements GlobalOperationCustomizer {
        private final Repositories repositories;
    
        @Override
        public Operation customize(Operation operation, HandlerMethod handlerMethod) {
            MethodParameter[] methodParameters = handlerMethod.getMethodParameters();
    
            for (MethodParameter methodParameter : methodParameters) {
                if (!repositories.hasRepositoryFor(methodParameter.getParameterType())) {
                    continue;
                }
                PathVariable pathVariable = methodParameter.getParameterAnnotation(PathVariable.class);
                RequestParam requestParam = methodParameter.getParameterAnnotation(RequestParam.class);
                String parameterName = methodParameter.getParameterName();
                if (pathVariable != null) {
                    parameterName = pathVariable.name();
                }
                if (requestParam != null) {
                    parameterName = requestParam.name();
                }
                if (StrUtil.isBlank(parameterName)) {
                    log.warn("参数名为空,无法获取参数名,跳过该参数");
                    continue;
                }
    
                Parameter parameter = new Parameter();
                parameter.setName(parameterName);
                if (pathVariable != null) {
                    parameter.setIn("path");
                } else if (requestParam != null) {
                    parameter.setIn("query");
                }
    
                RepositoryInformation information = repositories.getRequiredRepositoryInformation(methodParameter.getParameterType());
                TypeDescriptor idTypeDescriptor = information.getIdTypeInformation().toTypeDescriptor();
                Schema<?> schema;
                PrimitiveType primitiveType = PrimitiveType.fromType(idTypeDescriptor.getType());
                schema = primitiveType.createProperty();
                parameter.setSchema(schema);
                parameter.addExtension("x-is-domain-id", true);
                operation.getParameters().removeIf(p -> {
                    return p.getIn().equals(parameter.getIn()) && p.getName().equals(parameter.getName());
                });
                operation.getParameters().add(parameter);
            }
            return operation;
        }
    }
    

    至此,对加解密ID的所有适配工作就都完成了。将EncryptIdConfig.ENABLED置为true即可开启加密。

    要注意的是,由于我的系统没有使用json传参,所以这里并没有适配json传参的方式,要适配json的传参方式,可以根据自己使用的反序列化类库来扩展就行,jackson,fastjson,gson这些都可以扩展反序列化方式,这里不再赘述。

    当时也考虑过直接将PO中的ID设置为EncryptId类型的方式,但由于hibernate不支持嵌入类ID接入自增功能,所以放弃了。

    总结:其实主要是对数据的出入这一层适配,扩展类型转换系统,还有openapi的同步更新,系统中所有DTO/VO中ID都可以用特定的类EncryptId来表示,以后做一些其他的扩展也方便。

    相关文章

      网友评论

          本文标题:保护你的递增数值类型ID

          本文链接:https://www.haomeiwen.com/subject/oijmvdtx.html