加密算法参考: 浅谈常见的七种加密算法及实现
加密算法参考: 加密算法(DES,AES,RSA,MD5,SHA1,Base64)比较和项目应用
目的: 通过对API
接口请求报文签名,后端进行验签处理,实现接口参数防篡改的目的。
在现如今的Web
开发,或者是涉及到H5
、Android APP
、IOS APP
、小程序
等移动端
开发时,都是需要后端提供Api接口
来给前端调用,那么接口安全问题就被大家重视起来了,尤其是一些架构师,在项目架构过程中,需要着重考虑安全问题;说到安全问题,最常见漏洞就是在传统的接口在传输的过程中,很容易被一些黑客技术者截取请求报文,对报文抓包分析,然后更改请求头里面的重要参数值,进而伪造请求信息达到目的。仔细思考一下如果我们的项目不对请求信息做一些防攻击、防抓包篡改的话,太过于危险,尤其涉及到金额的项目安全系数需要做到更严格,这时候针对一些常规项目我们可以通过对请求头参数及整条请求报文信息进行签名、加盐验签处理,然后做到请求的幂等性控制,这种做法使用上较为普遍。
数据加密 的基本过程,就是对原来为 明文 的文件或数据按 某种算法 进行处理,使其成为 不可读 的一段代码,通常称为 “密文”。通过这样的途径,来达到 保护数据 不被 非法人窃取、阅读的目的。
加密 的 逆过程 为 解密,即将该 编码信息 转化为其 原来数据 的过程。
加密技术通常分为三大类: 对称式、非对称式、Hash算法。
2.1 对称性加密算法:AES、DES、3DES -可解密
1. 对称式加密解释: 就是加密和解密使用同一个密钥。信息接收双方都需事先知道密匙和加解密算法且其密匙是相同的,之后便是对数据进行加解密了。对称加密算法用来对敏感数据等信息进行加密。
2. 数据加密过程: 在对称加密算法中,数据发送方 将 明文 (原始数据) 和 加密密钥 一起经过特殊 加密处理,生成复杂的 加密密文 进行发送。
数据加密过程:在对称加密算法中,数据发送方 将 明文 (原始数据) 和 加密密钥 一起经过特殊 加密处理,生成复杂的 加密密文 进行发送。
3. 数据解密过程: 数据接收方 收到密文后,若想读取原数据,则需要使用 加密使用的密钥 及相同算法的 逆算法 对加密的密文进行解密,才能使其恢复成 可读明文。
4. 优点: 对称加密算法的运行速度比非对称加密算法的速度快很多,当我们需要加密大量的数据时,建议采用对称加密算法,提高加解密速度。
5. 算法选择: 通常情况下,秘钥越长,需要解密的时间就越久,程序运行的速度就越慢,AES
建议采用128
位,对称性加密中使用最多的是AES
加密算法。
2.2 非对称性加密算法:RSA、DSA、ECC -可解密
1. 非对称式加密: 又称为 公开密钥加密算法。它需要两个密钥,一个称为 公开密钥 (public key),即 公钥,另一个称为 私有密钥 (private key),即 私钥。因为 加密 和 解密 使用的是两个不同的密钥,所以这种算法称为 非对称加密算法。
2. 加解密: 如果使用 公钥 对数据 进行加密,只有用对应的 私钥 才能 进行解密。
3. 加解密: 如果使用 私钥 对数据 进行加密,只有用对应的 公钥 才能 进行解密。
4. 使用场景: 甲方公司生成 一对密钥, 并将其中的一把作为 公钥 向其它第三方公开,任何第三方都可以得到该公钥,得到该公钥的第三方使用该密钥对机密信息进行加密后再发送给甲方,甲方再使用自己保存的另一把 专用密钥 (私钥),对 加密 后的信息 进行解密。( 现实中对接支付宝支付技术就是使用RSA
非对称性加密。)
5. 缺点: 非对称加密的缺点是其加解密速度要远远慢于对称加密,密钥尺寸大,加解密速度慢,一般用来加密少量数据,在某些极端情况下,甚至能比非对称加密慢上1000倍,适用于少量数据加密的情况下。
6. 算法选择: 通常情况下,秘钥越长,需要解密的时间就越久,程序运行的速度就越慢,非对称性加密中使用最多的是RSA
加密算法。
2.3 散列算法(签名算法):MD5、SHA1、HMAC -不可逆
1. 散列算法: 散列算法又称哈希函数(Hash算法)
,是一种单向加密算法。在信息安全技术中,经常需要验证消息的完整性,散列(Hash)
函数提供了这一服务,它对不同长度的输入消息,产生固定长度的输出。这个固定长度的输出称为原输入消息的"散列"或"消息摘要"(Message digest)
。散列算法不算加密算法,因为其结果是不可逆的,既然是不可逆的,那么当然不是用来加密的,而是签名。
2. 使用场景: 主要用于验证,防止信息被篡改。具体用途如: 文件校验、数字签名、HTTP请求报文验签、鉴权协议、一致性验证、安全访问认证。(a. nacos
分布式配置中心原理就是基于MD5
对文件签名校验,MD5
不一致即配置文件需要被更新了。 b. 微信公众号开发接入开发者通过SHA1
散列算法签名实现。)
3. 算法选择: 通常情况下,秘钥越长,需要解密的时间就越久,程序运行的速度就越慢, 散列算法中使用最多的是MD5居多
,其次是SHA1
也不少。
2.4 其他常用算法:Base64
Base64
不是安全领域下的加解密算法,只是一个编码算法,通常用于把二进制数据编码为可写的字符形式的数据,特别适合在http
,mime
协议下的网络快速传输数据。UTF-8
和GBK
中文的Base64
编码结果是不同的。采用Base64
编码不仅比较简短,同时也具有不可读性,即所编码的数据不会被人用肉眼所直接看到,但这种方式很初级,很简单。经常使用Base64
可以对图片文件进行编码传输。
在环境搭建开始之前,先贴一下项目完整架子:
需求场景:
系统从外部获取数据时,通常采用API接口调用的方式来实现。请求方和接口提供方之间的通信过程,有这几个问题需要考虑:
1、请求参数是否被篡改;
2、请求来源是否合法;
3、请求是否具有唯一性。
今天跟大家探讨一下主流的通信安全解决方案。
设计思路:
APP_ID
和APP_SECRET
,APP_SECRET
存储与后端配置文件或者数据库中,APP_ID
通过请求头参数传递过来。APP_ID
和APP_SECRET
以及请求参数,按照一定算法生成签名Sign
,常用算法有SHA1
和MD5
,这里我们使用MD5
签名。appId
、timestamp(10位时间戳)
、nonce(随机数字符串)
、sign(签名结果)
。sign
值: sign = MD5(timestamp + appId + appSecret + nonce + version)
,其中 version
为接口提供方API
接口的统一版本号,version
默认值固定为1.0
。sign
,这一步骤可以在拦截器中实现,如果项目使用SpringCloud
可以在GateWay
网关层实现。实现思路:
Unix时间戳
)、生成随机字符串Nonce
、APPID
、APPSecret
、Version
MD5
签名算法 sign = MD5(timestamp + appId + appSecret + nonce + version)
,实现请求头参数签名,得到sign
值,然后将timestamp
、appId
、nonce
、sign
通过请求头参数传递,进行调用API
接口。timestamp
、appId
、nonce
、sign
,依次判断请求参数是否为空,为空则停止校验,返回响应。180
,则当前请求的timestamp
无效,如果小于180
,则当前请求的timestamp
为有效,防止API
接口被非法份子大流量请求攻击。redis
中的nonce
,确认当前请求是否为重复请求,控制API
接口幂等性。MD5
签名算法 sign = MD5(timestamp + appId + appSecret + nonce + version)
,实现签名,得到签名signEcrypt
值,然后与前端传过来的sign
值作比对,不一致则为非法请求。nonce
存进redis
,key= nonce
,value = nonce
, time = 180
(与时间戳差值一样)。 <parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.7.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.thinkingcao</groupId>
<artifactId>springboot-md5-encrypt</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-md5-encrypt</name>
<description>SpringBoot 系列教程(八十五):Spring Boot使用MD5加盐验签Api接口之前后端分离架构设计</description>
<!--编码设置-->
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--springboot web组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Lombok代码简化插件-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Apache摘要运算、编码解码工具包-->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!--commons-lang3-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- JSON解析fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.68</version>
</dependency>
<!-- springboot整合redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.1</version>
</dependency>
<!-- commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
# 端口
server:
port: 8025
spring:
application:
name: springboot-md5
redis:
# Redis数据库索引(默认为0)
database: 0
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password:
# 连接超时时间(毫秒)
timeout: 10000
jedis:
pool:
#连接池最大连接数(使用负值表示没有限制)
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8
# 连接池中的最小空闲连接
min-idle: 0
# 路径排除
project:
apifilter:
excludes: /api/user/login
# 设置日志级别为debug
logging:
level:
com.thinkingcao.encrypt: debug
package com.thinkingcao.encrypt.Inter;
import com.alibaba.fastjson.JSON;
import com.thinkingcao.encrypt.common.HeadRequest;
import com.thinkingcao.encrypt.constant.Constants;
import com.thinkingcao.encrypt.encrypt.MD5Util;
import com.thinkingcao.encrypt.result.ApiResult;
import com.thinkingcao.encrypt.utils.RedisUtils;
import com.thinkingcao.encrypt.utils.ServletUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @desc: API请求报文签名sign = timestamp+appId+appSecret+nonce+version
* @author: cao_wencao
* @date: 2020-05-18 14:54
*/
@Slf4j
@Component
public class SignAuthInterceptor implements HandlerInterceptor {
private static final String NONCE_KEY = "x-nonce-";
@Autowired
private RedisUtils redisUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String appId = request.getHeader("appId");
if (StringUtils.isBlank(appId)){
log.debug("appId不能为空...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("appId不能为空")));
return false;
}
String timestampStr = request.getHeader("timestamp");
if (StringUtils.isBlank(timestampStr)){
log.debug("timestamp不能为空...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("timestamp不能为空")));
return false;
}
String sign = request.getHeader("sign");
if (StringUtils.isBlank(sign)){
log.debug("sign不能为空...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("sign不能为空")));
return false;
}
String nonce = request.getHeader("nonce");
if (StringUtils.isBlank(nonce)){
log.debug("nonce不能为空...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("nonce不能为空")));
return false;
}
String signEcrypt = MD5Util.md5(appId + Constants.APP_SECRET + timestampStr + nonce + new HeadRequest().getVersion());
long timestamp = 0;
try {
timestamp = Long.parseLong(timestampStr);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
//1.前端传过来的时间戳与服务器当前时间戳差值大于180,则当前请求的timestamp无效
if (Math.abs(timestamp - System.currentTimeMillis() / 1000) > 180){
log.debug("timestamp无效...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("timestamp无效")));
return false;
}
//2.通过判断redis中的nonce,确认当前请求是否为重复请求,控制API接口幂等性
boolean nonceExists = redisUtils.hasKey(nonce);
if (nonceExists){
log.debug("nonce重复...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("重复的请求")));
return false;
}
//3.通过后台MD5重新签名校验与前端签名sign值比对,确认当前请求数据是否被篡改
if (!(sign.equalsIgnoreCase(signEcrypt))){
log.debug("sign签名校验失败...........");
ServletUtils.renderString(response, JSON.toJSONString(ApiResult.fail("sign签名校验失败")));
return false;
}
//4.将nonce存进redis
redisUtils.set(NONCE_KEY+nonce, nonce, 180);
log.debug("签名校验通过,放行...........");
//5.放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
package com.thinkingcao.encrypt.common;
import lombok.Data;
/**
* @desc: 请求头公共请求参数
* @author: cao_wencao
* @date: 2020-05-14 17:18
*/
@Data
public class HeadRequest {
/**
* appId
*/
private String appId;
/**
* appSecret秘钥
*/
private String appSecret;
/**
* 10位时间戳
*/
private String timestamp;
/**
* 参数签名
*/
private String sign;
/**
* 随机字符串
*/
private String nonce;
/**
* api版本号
*/
private String version = "1.0";
}
package com.thinkingcao.encrypt.encrypt;
import org.apache.commons.codec.digest.DigestUtils;
/**
* @desc: 基于apache.commons.codec封装摘要运算、编码解码工具类
* @auth: cao_wencao
* @date: 2020-05-09 17:59
*/
public class MD5Util {
/**
* 加密方法
* @param str
* @return
*/
public static String md5(String str) {
return DigestUtils.md5Hex(str);
}
//固定盐
private static final String salt = "30c722c6acc64306a88dd93a814c9f0a";
/**
* 将用户输入的明文密码与固定盐进行拼装后再进行MD5加密
* @param inputPass
* @return
*/
public static String inputPassToFormPass(String inputPass) {
String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
System.out.println(str);
return md5(str);
}
/**
* 将form表单中的密码转换成数据库中存储的密码
* @param formPass
* @param salt 随机盐
* @return
*/
public static String formPassToDBPass(String formPass, String salt) {
String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);
return md5(str);
}
public static String inputPassToDbPass(String inputPass, String saltDB) {
String formPass = inputPassToFormPass(inputPass);
String dbPass = formPassToDBPass(formPass, saltDB);
return dbPass;
}
}
package com.thinkingcao.encrypt.utils;
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.concurrent.TimeUnit;
/**
* Redis工具类
*/
@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 默认过期时长,单位:秒
*/
public final static long DEFAULT_EXPIRE = 60 * 60 * 24;
/**
* 不设置过期时长
*/
public final static long NOT_EXPIRE = -1;
/**
* 插入对象
*
* @param key 键
* @param value 值
* @author zmr
*/
public void setObject(String key, Object value) {
set(key, value, DEFAULT_EXPIRE);
}
/**
* 删除缓存
*
* @param key 键
* @author zmr
*/
public void delete(String key) {
redisTemplate.delete(key);
}
/**
* 返回指定类型结果
*
* @param key 键
* @param clazz 类型class
* @return
* @author zmr
*/
public <T> T get(String key, Class<T> clazz) {
String value = get(key);
return value == null ? null : fromJson(value, clazz);
}
/**
* Object转成JSON数据
*/
public String toJson(Object object) {
if (object instanceof Integer || object instanceof Long || object instanceof Float || object instanceof Double
|| object instanceof Boolean || object instanceof String) {
return String.valueOf(object);
}
return JSON.toJSONString(object);
}
/**
* JSON数据,转成Object
*/
private <T> T fromJson(String json, Class<T> clazz) {
return JSON.parseObject(json, clazz);
}
/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间(秒)
* @return
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 根据key 获取过期时间
*
* @param key 键 不能为null
* @return 时间(秒) 返回0代表为永久有效
*/
public long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
/**
* 判断key是否存在
*
* @param key 键
* @return true 存在 false不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 删除缓存
*
* @param key 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}
/**
* 普通缓存获取
*
* @param key 键
* @return 值
*/
public String get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key).toString();
}
/**
* 普通缓存放入
*
* @param key 键
* 94
* @param value 值
* 95
* @return true成功 false失败
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 普通缓存放入并设置时间
*
* @param key 键
* 111
* @param value 值
* 112
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
* 113
* @return true成功 false 失败
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
package com.thinkingcao.encrypt.utils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
/**
* 客户端工具类
*
*/
public class ServletUtils {
/**
* 获取request
*/
public static HttpServletRequest getRequest() {
return getRequestAttributes().getRequest();
}
/**
* 获取response
*/
public static HttpServletResponse getResponse() {
return getRequestAttributes().getResponse();
}
/**
* 获取session
*/
public static HttpSession getSession() {
return getRequest().getSession();
}
/**
* 获取ServletRequestAttributes
*/
public static ServletRequestAttributes getRequestAttributes() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
}
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String string) {
try {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderResultString(ServletResponse response, String string) {
try {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
/**
* @desc:
* @author: cao_wencao
* @date: 2020-05-09 18:40
*/
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class DefaultWebMvcConfigurer implements WebMvcConfigurer {
@Autowired
private SignAuthInterceptor signAuthInterceptor;
/**
* //1.加入的顺序就是拦截器执行的顺序,
* //2.按顺序执行所有拦截器的preHandle
* //3.所有的preHandle 执行完再执行全部postHandle 最后是postHandle
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(signAuthInterceptor)
.addPathPatterns("/**"); //所有请求都需要进行报文签名sign
registry.addInterceptor(permissionInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("api/user/login"); //排除登录Token拦截
}
}
import com.alibaba.fastjson.JSONObject;
import com.thinkingcao.encrypt.common.HeadRequest;
import com.thinkingcao.encrypt.common.RequestMessage;
import com.thinkingcao.encrypt.constant.Constants;
import com.thinkingcao.encrypt.encrypt.MD5Util;
/**
* @desc:
* @author: cao_wencao
* @date: 2020-05-15 11:54
*/
public class HeadTest {
private static final String APP_ID = "572b8e84346a419687422346bb51d0b5";
private static final String nonce = "62a439ab5d4e433c897f4b459181b2b3";
private static final String timesTamp = "1585881033";
public static void main(String[] args) {
RequestMessage requestData = new RequestMessage();
// String timesTamp = String.valueOf( System.currentTimeMillis() / 1000);
// String nonce = UUIDUtil.getUuid();
HeadRequest head = new HeadRequest();
head.setAppId(APP_ID);
head.setAppSecret(Constants.APP_SECRET);
head.setTimestamp(timesTamp);
head.setNonce(nonce);
head.setVersion("1.0");
JSONObject jsonObj = new JSONObject();
jsonObj.put("username", "zhangsan");
jsonObj.put("passward", "111111");
requestData.setBody(jsonObj);
requestData.setHead(head);
String sign = MD5Util.md5(APP_ID + Constants.APP_SECRET + timesTamp + nonce + "1.0");
requestData.setSign(sign);
System.out.println("sign = " + sign);
String messageResult = JSONObject.toJSONString(requestData);
System.out.println("messageResult = " + messageResult);
}
}
a. 请求头传递报文签名参数:
b. 请求体传递接口请求参数:
本文仅贴出设计思路中涉及的关键部分代码作以说明讲解,有兴趣的小可爱可以拉取GitHub完整代码进行研究、欢迎大家阅读、加以指正批评。
源码: https://github.com/Thinkingcao/SpringBootLearning/tree/master/springboot-md5-encrypt