Logback通用日志字段加密探讨
前言
在企业级应用中,日志中经常包含敏感信息(如手机号、身份证号、银行卡号等),这些信息如果明文存储可能会带来安全风险。本文介绍一个基于Logback的通用字段加密的解决方案。这个解决方案实现了指定加密字段,指定或自定义加密方式,可使用全局或局部生效的方式。解决了自己实现日志包装类时导致日志定位错误的问题。
技术实现
1. Logback转换器的实现原理
Logback提供了一个扩展点ClassicConverter,通过继承这个类,我们可以实现自定义的日志转换逻辑。以一个简单的手机号加密为例:
public class MobileEncryptConverter extends ClassicConverter {
/*
* XXTEAUtil加解密使用的key
* */
public static final String XXTEAUtil_KEY = "a$fHDF&G;lNFj%ea";
private static final Pattern MOBILE_PATTERN = Pattern.compile("(mobile:)(\\d+)");
private static final Pattern JSON_MOBILE_PATTERN = Pattern.compile("(\"mobile\":\")(\\d+)(\")");
private static final Pattern TO_STRING_MOBILE_PATTERN = Pattern.compile("(mobile=)(\\d+)");
@Override
public String convert(ILoggingEvent event) {
String message = event.getFormattedMessage();
try {
if (message.contains("mobile:")) {
// 加密普通文本中的 mobile 字段
message = encryptMobileField(message, MOBILE_PATTERN);
} else if (message.contains("\"mobile\":")) {
// 加密 JSON 格式中的 mobile 字段
message = encryptMobileField(message, JSON_MOBILE_PATTERN);
} else if (message.contains("mobile=")) {
// 加密 toString 格式中的 mobile 字段
message = encryptMobileField(message, TO_STRING_MOBILE_PATTERN);
}
} catch (Exception e) {
// 处理加密异常
return "ENCRYPTION_ERROR";
}
return message;
}
private String encryptMobileField(String message, Pattern pattern) {
Matcher matcher = pattern.matcher(message);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String encryptedMobile = XXTEAUtil.encryptToBase64String(matcher.group(2), XXTEAUtil_KEY);
matcher.appendReplacement(sb, matcher.group(1) + encryptedMobile + (matcher.groupCount() == 3 ? matcher.group(3) : ""));
}
matcher.appendTail(sb);
return sb.toString();
}
}
在本项目中,我们实现了FieldEncryptConverter来处理字段加密:
public class FieldEncryptConverter extends ClassicConverter {
private FieldEncryptConfig config;
private final Map<String, List<Pattern>> fieldPatterns = new HashMap<>();
private final Map<String, EncryptStrategy> encryptStrategies = new HashMap<>();
@Override
public void start() {
config = loadConfig();
// 为每个字段编译正则并创建加密策略
config.getEncryptFields().forEach((fieldName, field) -> {
try {
// 编译正则
List<Pattern> patterns = field.getPatterns().stream()
.map(pattern -> Pattern.compile(String.format(pattern, fieldName)))
.collect(Collectors.toList());
fieldPatterns.put(fieldName, patterns);
// 创建加密策略
EncryptStrategy strategy = EncryptStrategyFactory.createStrategy(
field.getEncryptType(),
field.getEncryptKey()
);
encryptStrategies.put(fieldName, strategy);
} catch (Exception e) {
logger.warn("Failed to compile pattern for field: {}", fieldName, e);
}
});
super.start();
}
@Override
public String convert(ILoggingEvent event) {
String message = event.getFormattedMessage();
// 对每个字段进行加密
for (Map.Entry<String, List<Pattern>> entry : fieldPatterns.entrySet()) {
String fieldName = entry.getKey();
if (message.contains(fieldName)) {
for (Pattern pattern : entry.getValue()) {
message = encryptField(message, pattern, fieldName);
}
}
}
return message;
}
}
这个实现的关键点在于:
初始化阶段(start方法):
- 加载配置文件
- 预编译正则表达式
- 初始化加密策略
转换阶段(convert方法):
- 获取原始日志消息
- 检查是否包含需要加密的字段
- 使用正则表达式匹配并加密相关字段
2. 配置加载机制
配置加载采用了分层设计,支持多种配置方式:
private FieldEncryptConfig loadConfig() {
FieldEncryptConfig config = new FieldEncryptConfig();
try {
Properties props = new Properties();
InputStream asStream = getClass().getClassLoader()
.getResourceAsStream("field-encrypt.properties");
if (asStream != null){
InputStreamReader reader = new InputStreamReader(asStream, StandardCharsets.UTF_8);
props.load(reader);
return loadFromProperties(props);
}
} catch (Exception e) {
logger.warn("Failed to load encryption configuration: {}", e.getMessage());
// 使用默认配置
config.addField("mobile", "XXTEA", "defaultKey");
}
return config;
}
配置文件示例:
# 需要加密的字段列表
encrypt.fields=mobile,idCard,email
# mobile字段配置
mobile.encrypt.type=XXTEA
mobile.encrypt.key=your-key-1
mobile.patterns=(%s:)([^,}\\s]+),(\"%s\":\")(.*?)(\"}?[,}])
3. 加密策略的SPI机制
项目使用Java SPI机制实现加密算法的可插拔设计:
public interface EncryptStrategy {
String encrypt(String value);
String decrypt(String value);
}
public interface EncryptStrategyProvider {
String getType();
EncryptStrategy createStrategy(String key);
}
加密策略工厂的实现:
public class EncryptStrategyFactory {
private static final Map<String, EncryptStrategyProvider> providers = new HashMap<>();
static {
ServiceLoader<EncryptStrategyProvider> loader = ServiceLoader.load(EncryptStrategyProvider.class);
for (EncryptStrategyProvider provider : loader) {
providers.put(provider.getType().toUpperCase(), provider);
}
}
public static EncryptStrategy createStrategy(String type, String key) {
EncryptStrategyProvider provider = providers.get(type.toUpperCase());
if(provider == null) {
throw new IllegalArgumentException("Unsupported encryption type: " + type);
}
return provider.createStrategy(key);
}
}
4. 字段加密的核心实现
字段加密的核心逻辑在encryptField方法中:
private String encryptField(String message, Pattern pattern, String fieldName) {
Matcher matcher = pattern.matcher(message);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String replacement;
int count = matcher.groupCount();
if (count >= 3) {
if (matcher.group(1) == null) {
continue;
}
String value = matcher.group(3).replaceAll("[\\\\\"]+$", "");
String encrypted = encryptStrategies.get(fieldName).encrypt(value);
String replacement1 = matcher.group(1) + matcher.group(2) +
encrypted + matcher.group(2);
replacement = replacement1.replace("\\", "\\\\");
} else {
String encrypt = encryptStrategies.get(fieldName).encrypt(matcher.group(2));
replacement = matcher.group(1) + encrypt +
(matcher.groupCount() == 3 ? matcher.group(3) : "");
}
matcher.appendReplacement(sb, replacement);
}
matcher.appendTail(sb);
return sb.toString();
}
这个方法的关键点:
- 正则匹配:使用预编译的Pattern进行匹配
- 分组处理:根据不同的匹配组数采用不同的处理策略
- 转义处理:处理JSON字符串中的转义字符
- 替换操作:使用StringBuffer进行高效的字符串替换
5. 内置加密算法实现
5.1 XXTEA加密实现
public class XXTeaEncrypt implements EncryptStrategy {
private final String key;
public XXTeaEncrypt(String key) {
this.key = key;
}
@Override
public String encrypt(String value) {
return XXTEAUtil.encryptToBase64String(value, key);
}
@Override
public String decrypt(String value) {
return new String(XXTEAUtil.decryptBase64String(value, key));
}
}
5.2 Base64编码实现
public class Base64Encrypt implements EncryptStrategy {
@Override
public String encrypt(String value) {
return Base64.getEncoder().encodeToString(value.getBytes());
}
@Override
public String decrypt(String value) {
return new String(Base64.getDecoder().decode(value));
}
}
自定义加密方式
你可以通过以下步骤添加自定义的加密方式:
- 实现
EncryptStrategy接口,创建你的加密策略类 - 实现
EncryptStrategyProvider接口,创建对应的Provider类 - 在
META-INF/services/com.laiu.log.spi.EncryptStrategyProvider文件中添加你的Provider类的全限定名 - 在配置文件中使用你的自定义加密类型
示例:
// 1. 实现加密策略
public class MyEncrypt implements EncryptStrategy{
@Override
public String encrypt(String value) {
//实现加密逻辑
return "";
}
@Override
public String decrypt(String value) {
//实现解密逻辑
return "";
}
}
// 2. 实现Provider
public class MyEncryptProvider implements EncryptStrategyProvider{
@Override
public String getType() {
return "MYTYPE";
}
@Override
public EncryptStrategy createStrategy(String key) {
return new MyEncrypt();
}
}
增强型安全日志实现
除了通过Logback转换器实现全局的日志加密外,项目还提供了两种增强型的安全日志实现:SecureLogger和FlexSecureLogger。这两个实现都解决了日志定位的问题,并提供了更灵活的加密控制。
1. SecureLogger实现
SecureLogger是一个通用的安全日志实现,它通过包装原始的SLF4J Logger实现日志加密:
public class SecureLogger implements Logger {
private final Logger slfLogger;
private final FieldEncryptService fieldEncryptService;
private final boolean isLocationAware;
private static final String FQCN = SecureLogger.class.getName();
public SecureLogger(Logger slfLogger) {
this.slfLogger = slfLogger;
this.fieldEncryptService = FieldEncryptService.getInstance();
this.isLocationAware = slfLogger instanceof LocationAwareLogger;
}
}
关键实现特点:
- 正确的日志定位
private void log(int level, String msg, Object[] args, Throwable throwable) {
String formattedMessage;
if (args != null && args.length > 0) {
FormattingTuple ft = MessageFormatter.arrayFormat(msg, args);
formattedMessage = fieldEncryptService.encryptMessage(ft.getMessage());
throwable = throwable == null ? ft.getThrowable() : throwable;
} else {
formattedMessage = fieldEncryptService.encryptMessage(msg);
}
if (isLocationAware) {
LocationAwareLogger locationAwareLogger = (LocationAwareLogger) slfLogger;
locationAwareLogger.log(null, FQCN, level, formattedMessage, null, throwable);
} else {
// 根据日志级别调用相应的方法
switch (level) {
case LocationAwareLogger.INFO_INT:
slfLogger.info(formattedMessage, throwable);
break;
// ... 其他日志级别
}
}
}
- 支持SLF4J的所有日志级别和格式
- 完整实现了SLF4J Logger接口
- 支持带有Marker的日志记录
- 支持参数化消息格式
2. FlexSecureLogger实现
FlexSecureLogger提供了更灵活的字段级加密控制,允许在运行时指定需要加密的字段:
public class FlexSecureLogger implements org.slf4j.Logger {
private final LocationAwareLogger log;
private final FieldEncryptService fieldEncryptService;
private static final String FQCN = FlexSecureLogger.class.getName();
public FlexSecureLogger(org.slf4j.Logger slfLogger) {
this.log = (LocationAwareLogger) slfLogger;
this.fieldEncryptService = FieldEncryptService.getInstance();
}
}
关键特性:
- 灵活的加密控制
public void infoWithEncryption(String message, Collection<String> fieldsToEncrypt, Object... args) {
logWithEncryption(LogLevel.INFO, message, fieldsToEncrypt, args);
}
private void logWithEncryption(int logLevel, String message, Collection<String> fieldsToEncrypt, Object... args) {
FormattingTuple tuple = MessageFormatter.arrayFormat(message, args);
String formattedMessage = tuple.getMessage();
Throwable throwable = tuple.getThrowable();
String encryptedMessage = encryptSpecificFields(formattedMessage, fieldsToEncrypt);
log.log(null, FQCN, logLevel, encryptedMessage, null, throwable);
}
- 支持动态字段加密
- 可以在每次日志记录时指定需要加密的字段
- 支持批量字段加密
- 保持了日志的可读性和灵活性
使用示例
- 使用SecureLogger
Logger originalLogger = LoggerFactory.getLogger(YourClass.class);
SecureLogger secureLogger = new SecureLogger(originalLogger);
secureLogger.info("User info - mobile: {}, idCard: {}", mobile, idCard);
- 使用FlexSecureLogger
Logger originalLogger = LoggerFactory.getLogger(YourClass.class);
FlexSecureLogger flexLogger = new FlexSecureLogger(originalLogger);
List<String> fieldsToEncrypt = Arrays.asList("mobile", "idCard");
flexLogger.infoWithEncryption("User info - mobile: {}, idCard: {}", fieldsToEncrypt, mobile, idCard);
实现优势
精确的日志定位
- 通过使用
LocationAwareLogger和正确的FQCN,确保日志能够准确定位到调用位置,实现原理就是获取当前线程的堆栈信息然后过滤掉当前的logger所在类,当然也可以不使用LocationAwareLogger自己获取堆栈信息实现。 - 避免了常见的日志包装器导致的行号不准确问题
- 通过使用
灵活的加密控制
SecureLogger提供全局加密能力FlexSecureLogger支持细粒度的字段加密控制
完整的日志功能支持
- 支持SLF4J的全部日志级别
- 支持参数化消息格式
- 支持异常堆栈记录
- 支持Marker标记
高性能设计
- 使用
LocationAwareLogger避免多余的堆栈遍历 - 仅在必要时进行消息格式化和加密
- 复用
FieldEncryptService实例
- 使用
性能优化设计
正则表达式预编译
- 在转换器初始化时预编译所有正则表达式
- 避免运行时重复编译的开销
快速路径检查
- 使用
message.contains(fieldName)进行快速判断 - 避免不必要的正则匹配
- 使用
缓存策略
- 缓存加密策略实例
- 避免重复创建加密对象
高效的字符串处理
- 使用StringBuffer进行字符串替换
- 批量处理正则匹配结果
异常处理机制
项目实现了多层异常处理:
配置加载异常
- 配置文件不存在时使用默认配置
- 配置格式错误时记录警告日志
加密异常
- 单个字段加密失败不影响其他字段
- 提供错误信息占位符替换
正则匹配异常
- 捕获并记录编译错误
- 跳过错误的正则表达式
扩展性设计
新增加密算法
- 实现EncryptStrategy接口
- 创建对应的Provider
- 在META-INF/services中注册
自定义正则模式
- 通过配置文件定义匹配模式
- 支持多个模式并行匹配
配置源扩展
- 可以扩展配置加载机制
- 支持多种配置源(文件、配置中心等)
总结
这个日志字段加密方案的实现充分利用了Java和Logback的特性:
- 利用Logback的扩展机制实现自定义转换器
- 使用Java SPI机制实现可插拔的加密算法
- 采用正则表达式实现灵活的字段匹配
- 通过多层设计实现了高扩展性和可维护性
同时,通过各种优化手段确保了加密过程的高效性,通过完善的异常处理确保了系统的稳定性。这个方案不仅解决了日志中敏感信息的加密需求,还提供了一个可扩展的框架,方便后续添加新的加密算法和匹配规则。
探讨
我们一般在项目中使用日志时使用方式都是使用@Slf4j这种方式直接在编译后自动帮我们生成了一行 private static final Logger log = LoggerFactory.getLogger(xxx.class);我自己在实现上述日志加密的过程中也在尝试通过注解+处理器的方式通过JCTree等方式实现了在代码编译后生成这一行注解的方式,具体的实现方式见代码中的SecureLogProcessor类:
编译期代码添加生成
去掉这个注释//@AutoService(Processor.class)即可实现自动生成log
import com.google.auto.service.AutoService;
import com.sun.tools.javac.api.JavacTrees;
import com.sun.tools.javac.code.Flags;
import com.sun.tools.javac.processing.JavacProcessingEnvironment;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.tree.TreeTranslator;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.List;
import com.sun.tools.javac.util.Names;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.util.Set;
//@AutoService(Processor.class)
@SupportedAnnotationTypes("com.laiu.log.annotation.SecureLog")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class SecureLogProcessor extends AbstractProcessor {
private JavacTrees trees;
private TreeMaker treeMaker;
private Names names;
private Context context;
private Messager messager;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.trees = JavacTrees.instance(processingEnv);
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
this.treeMaker = TreeMaker.instance(context);
this.names = Names.instance(context);
this.context = context;
this.messager = processingEnv.getMessager();
}
private void debug(String msg) {
messager.printMessage(Diagnostic.Kind.NOTE, "SecureLogProcessor: " + msg);
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
debug("Starting annotation processing");
if (!roundEnv.processingOver()) {
for (TypeElement annotation : annotations) {
debug("Processing annotation: " + annotation);
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(annotation);
debug("Found " + elements.size() + " annotated elements");
elements.stream()
.filter(element -> element.getKind() == ElementKind.CLASS)
.forEach(element -> {
JCTree tree = trees.getTree(element);
tree.accept(new TreeTranslator() {
@Override
public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
super.visitClassDef(jcClassDecl);
// 只有在字段不存在时才添加
if (!hasLoggerField(jcClassDecl)) {
jcClassDecl.defs = jcClassDecl.defs.prepend(createLoggerField(element.getSimpleName().toString()));
}
}
});
});
}
}
return true;
}
private boolean hasLoggerField(JCTree.JCClassDecl classDecl) {
for (JCTree def : classDecl.defs) {
if (def instanceof JCTree.JCVariableDecl) {
JCTree.JCVariableDecl var = (JCTree.JCVariableDecl) def;
if (var.name.toString().equals("logger")) {
return true;
}
}
}
return false;
}
private JCTree.JCVariableDecl createLoggerField(String className) {
// 创建 LoggerFactory.getLogger(XXX.class) 表达式
JCTree.JCFieldAccess loggerFactorySelect = treeMaker.Select(
treeMaker.Ident(names.fromString("LoggerFactory")),
names.fromString("getLogger"));
JCTree.JCFieldAccess classSelect = treeMaker.Select(
treeMaker.Ident(names.fromString(className)),
names.fromString("class"));
JCTree.JCMethodInvocation getLoggerCall = treeMaker.Apply(
List.nil(),
loggerFactorySelect,
List.of(classSelect));
// 创建 new SecureLogger(...) 表达式
JCTree.JCNewClass newSecureLogger = treeMaker.NewClass(
null,
List.nil(),
treeMaker.Ident(names.fromString("SecureLogger")),
List.of(getLoggerCall),
null);
// 创建字段声明
return treeMaker.VarDef(
treeMaker.Modifiers(Flags.PRIVATE | Flags.STATIC | Flags.FINAL),
names.fromString("logger"),
treeMaker.Ident(names.fromString("SecureLogger")),
newSecureLogger);
}
}
这个处理器的主要功能是:
自动注入:为带有@SecureLog注解的类自动注入一个SecureLogger字段 编译时处理:在编译阶段完成处理,不影响运行时性能 安全检查:避免重复添加logger字段 AST操作:使用JavacTrees和TreeMaker操作抽象语法树 实际效果就是,当你在类上添加@SecureLog注解时:
@SecureLog
public class UserService {
// 你的代码
}
编译后会自动生成:
@SecureLog
public class UserService {
private static final SecureLogger logger = new SecureLogger(LoggerFactory.getLogger(UserService.class));
// 你的代码
}
这样就可以在类中直接使用logger进行安全日志记录,而不需要手动声明logger字段。
问题
这种方式虽然在编译器生成了logger,但是我们在写代码时是不能像@Slf4j这个注解这样直接使用log.info等方法,因为代码是在编译器生成的,但是我们此时的代码还没进行编译呢,所以直接使用logger.info等方法显然是不行的,为什么@Slf4j可以实现这样呢,因为这个@Slf4j注解是依赖lombok插件来实现的,Lombok使用自己的注解处理器(lombok.launch.AnnotationProcessorHider$AnnotationProcessor)
不仅生成字段,还会注入所有的日志方法,支持不同的日志框架(SLF4J、Log4j、JUL等),实现完整的IDE支持需要大量工作,所以这块是暂时想不到有什么别的方式了,所以还是多些一行代码吧:
private static final SecureLogger logger = new SecureLogger(LoggerFactory.getLogger(UserService.class)); :)
