当前位置: 首页>数据库>正文

保护你的递增数值类型ID

在数据库设计时,使用自增类型的数据库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来表示,以后做一些其他的扩展也方便。


https://www.xamrdz.com/database/6cj1995167.html

相关文章: