在數(shù)據(jù)庫設(shè)計時涵防,使用自增類型的數(shù)據(jù)庫ID有一個缺點,那就是返回到前端后亡电,容易被人猜解梁钾,例如有一個用戶的主頁的url為 /user/1,那將1自增就可以爬到系統(tǒng)所有的用戶逊抡,有些場景中這樣的風(fēng)險是不被允許的姆泻,必須新增額外的ID字段來解決這個問題。本文則是提供另一種加密的思路來保護(hù)ID不被猜解冒嫡。
優(yōu)點
統(tǒng)一實現(xiàn)系統(tǒng)中所有自增數(shù)值類ID的加密保護(hù)
靈活配置拇勃,插件式設(shè)計,用就打開孝凌,不用就關(guān)閉
實現(xiàn)思路
要保證不被猜解順序的ID方咆,一定是沒有規(guī)律的,所以初步的加密算法可以是將Long類型的ID經(jīng)過AES加密再由BASE64編碼得到蟀架,這樣的ID可解密瓣赂,不具備枚舉性,只要不泄露AES的密鑰片拍,基本是安全的煌集。由于加密后的ID可能需要在url中傳輸,所以base64編碼時要使用url安全的編碼方式
由于數(shù)據(jù)庫層是bitint類型ID自增捌省,所以只在controller入?yún)⒑统鰠⑦@一層做ID的轉(zhuǎn)換即可苫纤。這一點需要根據(jù)自己使用的框架做適配。下面是具體實現(xiàn)纲缓,這里用到的技術(shù)是springmvc+springdata-jpa+querydsl+openapi3+modelmapper卷拘,這里只列出需要適配的一些技術(shù)棧,主要是view層數(shù)據(jù)到service層需要進(jìn)行ID的解密祝高,還有一些文檔和實體轉(zhuǎn)換工具的配置代赁,具體咱往下看额划。
ID加解密工具類
/**
* 提供ID和字符串的互相轉(zhuǎn)換泳姐,避免數(shù)值型的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 待加密的消息,實際加密內(nèi)容為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 待解密內(nèi)容
* @return 解密后的內(nèi)容
*/
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 解密后的類颓屑,需實現(xiàn)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,數(shù)據(jù)庫使用bigint器腋,返回給前端對應(yīng)的字符串,使得無法猜解其他數(shù)據(jù)的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等庫會使用該方法進(jìn)行構(gòu)造ID對象
return encryptIdValueOf(encryptId);
}
}
出參DTO轉(zhuǎn)換適配
在BaseDTO中钩杰,使用這個ID纫塌,我這里所有用到ID的DTO都繼承了BaseDTO,所以只改BaseDTO就可以了讲弄,Long改為EncryptId
/**
* DTO基類
*/
@Getter
@Setter
public abstract class BaseDTO implements Serializable {
protected EncryptId id;
...
}
我的項目中DTO和PO的轉(zhuǎn)換都使用了modelmapper措左,所以要告訴modelmapper如何轉(zhuǎn)換Long和EncryptId類型,如果你使用了其他的對象轉(zhuǎn)換工具避除,也需要告訴他如何轉(zhuǎn)換怎披。有一個通用的做類型轉(zhuǎn)換的工具很重要,不然需要每個地方去改造瓶摆,有點成本太高了凉逛。
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的轉(zhuǎn)換,因為入?yún)⒌霓D(zhuǎn)換我使用spring的轉(zhuǎn)換系統(tǒng)(下面會說到)群井,這個要看自己是使用誰轉(zhuǎn)換的状飞,靈活配置就行。
PO中繼續(xù)使用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;
...
}
至此书斜,系統(tǒng)中出參的地方基本改造完畢(如果你所有的DTO都繼承BaseDTO的話)诬辈,還有就是其他DTO里有一些單獨的Long id,也需要更換為EncryptId類型荐吉,這個需要逐項檢查了焙糟。
入?yún)TO轉(zhuǎn)換適配
普通方式的入?yún)⒍家?jīng)過spring的轉(zhuǎn)換系統(tǒng)轉(zhuǎn)換類型,所以在這里配置稍坯,我們分別配置了String轉(zhuǎn)EncryptId酬荞,EncryptId轉(zhuǎn)Long和String轉(zhuǎn)Long搓劫,在String轉(zhuǎn)Long這個轉(zhuǎn)換器中瞧哟,我們直接把String當(dāng)做了加密ID來處理轉(zhuǎn)換為long,這里多多少少是有點不合適的枪向,但由于只是在WebConversionService中注冊勤揩,這個轉(zhuǎn)換服務(wù)將都用于web層的轉(zhuǎn)換,而web層的裝換在自定義轉(zhuǎn)換器轉(zhuǎn)換失敗時會回退到默認(rèn)的PropertyEditor來轉(zhuǎn)換秘蛔,所以即使接收普通的Long類型參數(shù)也是能接收的陨亡。而String轉(zhuǎn)Long這個轉(zhuǎn)換器也是spring-data-jpa中擴(kuò)展功能傍衡,直接在controller接收PO參數(shù)時用到的,spring-data-jpa會先將數(shù)據(jù)轉(zhuǎn)換為ID類型也就是long负蠕。當(dāng)然更妥善的做法是禁用掉spring-data-jpa提供的功能自己實現(xiàn)蛙埂,由于不是public類這里就不處理了。
關(guān)于spring-提供的擴(kuò)展功能這里有介紹
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#core.web.basic
如果你沒有用到這個功能遮糖,那就無需注冊這個轉(zhuǎn)換器
@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類型參數(shù)時對Id的轉(zhuǎn)換绣的,
* 在controller中接收long類型參數(shù)時,這里轉(zhuǎn)換失敗欲账,springmvc會回退到propertyEditor中進(jìn)行轉(zhuǎn)換
* {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類型參數(shù)的接收支持
/**
* 接收EncryptId類型參數(shù)
*/
@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類參數(shù)屡江,但是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("參數(shù)名為空赛不,無法獲取參數(shù)名惩嘉,跳過該參數(shù)");
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即可開啟加密文黎。
要注意的是,由于我的系統(tǒng)沒有使用json傳參殿较,所以這里并沒有適配json傳參的方式臊诊,要適配json的傳參方式,可以根據(jù)自己使用的反序列化類庫來擴(kuò)展就行斜脂,jackson抓艳,fastjson,gson這些都可以擴(kuò)展反序列化方式帚戳,這里不再贅述玷或。
當(dāng)時也考慮過直接將PO中的ID設(shè)置為EncryptId類型的方式,但由于hibernate不支持嵌入類ID接入自增功能片任,所以放棄了偏友。
總結(jié):其實主要是對數(shù)據(jù)的出入這一層適配,擴(kuò)展類型轉(zhuǎn)換系統(tǒng)对供,還有openapi的同步更新位他,系統(tǒng)中所有DTO/VO中ID都可以用特定的類EncryptId來表示,以后做一些其他的擴(kuò)展也方便产场。