springboot代码级全局敏感信息加解密和脱敏方案
前言
- 对于金融机构(比如收单)来说,客户的账户信息的管理,是要满足一定的安全标准的。其中不限于:密码,卡号,邮箱,证件...
- 最近所在项目接到这个任务,要求:
- 加密入库,使用解密,脱敏和原文查看
- 业务无侵入姓
- 易用性 灵活性 性能考量
技术环境
<spring.cloud.alibaba.version>2.2.1.RELEASE</spring.cloud.alibaba.version>
<spring-cloud.version>Hoxton.SR3</spring-cloud.version>
<mybatisplus.version>3.3.1</mybatisplus.version>
整体思路
- 存库加密和出库解密,使用
mybatis 拦截器
- 加密字段的查询,使用
Aspect切面
,对输入原文加密后到库里equals
,该字段将放弃模糊查,有更好的方案欢迎交流... - 显示端应用脱敏有两种
- 前后端分离的考虑
JSON转换器
- 不分离的考虑
Aspect切面
,本篇使用切面
- 前后端分离的考虑
logback
日志输出脱敏的话,暂采用的方案是,对输出的日志字符串进行正则匹配替换,有更好的办法欢迎介绍啊
Code1:mybatis-plus拦截器实现入库加密出库解密
- 拦截添加和更新操作
/**
* 更新操作数据加密
* @作者 richardhe
* @创建时间 2021年7月22日 下午1:38:35
* @版本 1.0
*/
@Intercepts({
// 增删改
@Signature(type= Executor.class, method = "update", args = {MappedStatement.class,Object.class})
})
@Slf4j
public class MybatisUpdateEncryptInterceptor implements Interceptor {
@Autowired
private CryptService cryptService;
@Override
public Object intercept(Invocation invocation) throws Throwable {
/**
* 前置处理 implement pre processing if need
*/
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
log.debug("sqlCommandType:{}", sqlCommandType);
Object param = null;
// 新增操作
if(sqlCommandType == SqlCommandType.INSERT) {
param = invocation.getArgs()[1];
// 加密
cryptService.encryptBean(param);
}
// 更新操作
else if(sqlCommandType == SqlCommandType.UPDATE) {
// 实体参数
ParamMap<?> parameter = (ParamMap<?>) invocation.getArgs()[1];
param = parameter.values().toArray()[0];
// 加密
cryptService.encryptBean(param);
}
// 真正的 Excutor 执行
Object returnObject = invocation.proceed();
return returnObject;
}
}
- 拦截查询操作
/**
* 查询结果数据解密
* @作者 richardhe
* @创建时间 2021年7月22日 下午1:38:35
* @版本 1.0
*/
@Intercepts({
@Signature(type= ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
public class MybatisQueryDecryptInterceptor implements Interceptor {
@Autowired
private CryptService cryptService;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 真正的 Excutor 执行
Object returnObject = invocation.proceed();
/**
* 后置处理 implement post processing if need
*/
if(null == returnObject) {
return returnObject;
}
// handleResultSets返回结果一定是一个List
// size为1时,Mybatis会取第一个元素作为接口的返回值。
if(returnObject instanceof List) {
List<?> rsList = (List<?>) returnObject;
// 解密
decrypt(rsList);
}
return returnObject;
}
private void decrypt(List<?> rsList) {
// 目标类
if(CollUtil.isEmpty(rsList) || !rsList.get(0).getClass().isAnnotationPresent(Crypt.class)) {
return;
}
for(Object o:rsList) {
cryptService.decryptBean(o);
}
}
}
Code2-显示端应用切面掩码脱敏
/**
* 切面,敏感数据加掩码<br>
* @作者 richardhe
* @创建时间 2021年10月19日 上午11:35:15
* @版本 1.0
*/
@Slf4j
@Aspect
@Component
public class DataResponseMaskAspect {
@Autowired
private XxxAppProperties appProps;
@PostConstruct
private void init() {
log.info("{} 切面组件ioc注入", this.getClass().getSimpleName());
}
// 连接点1,针对Feign正常查询
@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
private void pointcut1() {}
@Pointcut("execution(* com.xxx.xxx.feign..*.get*(..))")
private void pointcut11() {}
@AfterReturning(value = "pointcut1() && pointcut11()", returning = "result")
public void adviceAfterReturning1(Object result) {
maskResult(result);
}
// 连接点2,针对Feign分页查询
@Pointcut("execution(* com.xxx.xxx.feign..*.queryPage(..))")
private void pointcut2() {}
@AfterReturning(value = "pointcut2()", returning = "result")
public void adviceAfterReturning2(Object result) {
maskResult(result);
}
// 连接点3,其他单独加约定的@Mask注解
@Pointcut("@annotation(com.xxx.crypt.mask.Mask)")
private void pointcut3() {}
@AfterReturning(value = "pointcut3()", returning = "result")
public void adviceAfterReturning3(Object result) {
maskResult(result);
}
/**
* 加掩码逻辑
* @param result
*/
private void maskResult(Object result) {
// 备用开关
if(appProps.isCloseMaskFunction()) {
return;
}
// 分页查询
if(result instanceof PageResp) {
PageResp<?> rs = (PageResp<?>)result;
if(rs.isSuccess() && CollUtil.isNotEmpty(rs.getPage().getRecords())
&& rs.getPage().getRecords().get(0).getClass().isAnnotationPresent(Mask.class)) {
rs.getPage().getRecords().forEach(o -> BaseMasker.maskBean(o));
}
return;
}
// 非分页查询
if(result instanceof BaseResp) {
BaseResp<?> rs = (BaseResp<?>)result;
if(rs.isSuccess()){
BaseMasker.maskTObject(rs.getData());
}
}
}
}
Code3-敏感字段查询功能-切面
- 目前方案,对输入的进行同等加密,然后去库里equals,这意味着放弃这些字段的模糊查询功能,有更好方案欢迎介绍...
/**
* 切面,查询参数加密<br>
* @作者 richardhe
* @创建时间 2021年10月19日 上午11:35:15
* @版本 1.0
*/
@Slf4j
@Aspect
@Component
@ConditionalOnBean(CryptService.class)
public class QueryParamEncryptAspect {
@Autowired
private AppProperties appProps;
@Autowired
private CryptService cryptService;
@PostConstruct
private void init() {
log.info("{} 切面组件ioc注入", this.getClass().getSimpleName());
}
// 连接点1,分页查处理
@Pointcut("execution(* com.xxx.**.controller.*Controller.queryPage(..))")
private void pointcut1() {}
@Before(value = "pointcut1()")
public void adviceBefore1(JoinPoint joinPoint) {
if(appProps.isCloseQueryParamEncrypt()) {
return;
}
// 方法参数
Object[] args = joinPoint.getArgs();
if(ArrayUtil.isNotEmpty(args)) {
BaseReq<?,?> pageReq = (BaseReq<?,?>) args[0];
Object cond = pageReq.getCond();
// 对象字段加密
cryptService.encryptBean(cond);
}
}
// 连接点2,目标注解方法,注意!!!参数请不要使用primitive type(int和long和boolean),请使用包装类
@Pointcut("@annotation(com.xxx.crypt.Crypt)")
private void pointcut2() {}
@Around(value = "pointcut2()")
public Object adviceBefore2(ProceedingJoinPoint joinPoint) throws Throwable {
if(appProps.isCloseQueryParamEncrypt()) {
return joinPoint.proceed();
}
Object[] newArgs = preHandle(joinPoint);
return joinPoint.proceed(newArgs);
}
private Object[] preHandle(ProceedingJoinPoint joinPoint) {
// 参数列表
Object[] args = joinPoint.getArgs();
if(ArrayUtil.isEmpty(args)) {
return args;
}
Object[] newArgs = args;
// 方法签名
MethodSignature signature= (MethodSignature) joinPoint.getSignature();
// 方法上的注解,切面已锁定
//Annotation[] annotations = signature.getMethod().getAnnotations();
// 方法参数注解
Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations();
for(int i=0; i<parameterAnnotations.length; i++) {
Annotation[] paramAnntArr = parameterAnnotations[i];
if(ArrayUtil.isEmpty(paramAnntArr) || null==newArgs[i]) {
continue;
}
for(Annotation annt:paramAnntArr) {
// 目标注解参数
if(annt instanceof Crypt) {
Object arg = newArgs[i];
// 字符串类型处理
if(arg instanceof String) {
String str = (String) arg;
String encrypt = cryptService.encrypt(str);
newArgs[i] = encrypt;
// Crypt对象处理
} else if(arg.getClass().isAnnotationPresent(Crypt.class)) {
cryptService.encryptBean(arg);
}
// 其他不处理
}
}
}
return newArgs;
}
}
Code4-logback日志脱敏
- 添加
MessageConverter
/**
* logback 脱敏转换器
* @作者 ricahrdhe
* @创建时间 2021年10月21日 下午3:31:42
* @版本 1.0
*/
public class SensitiveConverter extends MessageConverter {
@Override
public String convert(ILoggingEvent event) {
// INFO及以上的才脱敏处理
if(event.getLevel().isGreaterOrEqual(Level.INFO)) {
return BaseMasker.maskReplaceAll(super.convert(event));
}
return super.convert(event);
}
}
- logback配置
<configuration>
<conversionRule conversionWord="msg" converterClass="com.xxx.crypt.mask.SensitiveConverter"></conversionRule>
...
</configuration>
结语
本篇是从大方向分享下可行性方案,实践过程中会碰到很多细节坑,比如:
加解密算法
如何选择?使用第三方服务还是自实现?切面
怎么切好一点?Java反射
复习- 脱敏具体怎么脱?
Java正则表达式
复习 - 查看原文如何配合权限分配要求?
- ......
这些往细里说都可以单独成篇,闲来再补哈哈