保護(hù)你的遞增數(shù)值類型ID

在數(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ò)展也方便产场。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末鹅髓,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子京景,更是在濱河造成了極大的恐慌窿冯,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件确徙,死亡現(xiàn)場離奇詭異醒串,居然都是意外死亡执桌,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門芜赌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來仰挣,“玉大人,你說我怎么就攤上這事缠沈∽的荆” “怎么了?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵博烂,是天一觀的道長香椎。 經(jīng)常有香客問我,道長禽篱,這世上最難降的妖魔是什么畜伐? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮躺率,結(jié)果婚禮上玛界,老公的妹妹穿的比我還像新娘。我一直安慰自己悼吱,他們只是感情好慎框,可當(dāng)我...
    茶點故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著后添,像睡著了一般笨枯。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上遇西,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天馅精,我揣著相機與錄音,去河邊找鬼粱檀。 笑死洲敢,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的茄蚯。 我是一名探鬼主播压彭,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼渗常!你這毒婦竟也來了壮不?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤凳谦,失蹤者是張志新(化名)和其女友劉穎忆畅,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體尸执,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡家凯,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了如失。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片绊诲。...
    茶點故事閱讀 38,100評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖褪贵,靈堂內(nèi)的尸體忽然破棺而出掂之,到底是詐尸還是另有隱情,我是刑警寧澤脆丁,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布世舰,位于F島的核電站,受9級特大地震影響槽卫,放射性物質(zhì)發(fā)生泄漏跟压。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一歼培、第九天 我趴在偏房一處隱蔽的房頂上張望震蒋。 院中可真熱鬧,春花似錦躲庄、人聲如沸查剖。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽笋庄。三九已至,卻和暖如春倔监,著一層夾襖步出監(jiān)牢的瞬間无切,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工丐枉, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留哆键,地道東北人。 一個月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓瘦锹,卻偏偏與公主長得像籍嘹,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子弯院,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容