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