审计日志实现
目标
记录用户行为:
- 用户A 在xx时间 做了什么
- 用户B 在xx时间 改变了什么
针对以上场景,需要记录以下一些接口信息:
- 时间
- ip
- 用户
- 入参
- 响应
- 改变数据内容描述
- 标签-区分领域
效果
- 将此类信息单独输出log(可不选)
- 持久化储存,便于查询追踪
设计
- 提供两个信息记录入口:注解和api调用
- 信息通过log记录,输出到log和mq
- 消费mq数据,解析到ES做持久化
- 查询:根据时间,操作名称,标签进行检索
示意图
实现
属性封装
LcpAuditLog:数据实体
@Builder
@Data
public class LcpAuditLog implements Serializable {
private static final long serialVersionUID = -6309732882044872298L;
/**
* 操作人
*/
private String operator;
/**
* 操作(可指定,默认方法全路径)
*/
private String operation;
/**
* 操作时间
*/
private Date operateTime;
/**
* 参数(可选)
*/
private String params;
/**
* ip(可选)
*/
private String ip;
/**
* 返回(可选)
*/
private String response;
/**
* 标签
*/
private String tag;
/**
* 影响数据
*/
private String influenceData;
}
定义注解AuditLog
AuditLog注解,用于标记哪些方法需要做审计日志,与业务解耦。仅记录基本信息:时间,用户,操作,入参,响应。
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
/**
* 操作标识
*/
@AliasFor(value = "value")
String operation() default "";
@AliasFor(value = "operation")
String value() default "";
/**
* 标签
* @return
*/
String tag() default "";
}
注解实现类
常规做法,借助aop实现。
@Slf4j
@Aspect
@Order(2)
@Configuration
public class AuditLogAspect {
/**
* 单个参数最大长度
*/
private static final int PARAM_MAX_LENGTH = 5000;
private static final int RESULT_MAX_LENGTH = 20000;
@Resource(name = "logService")
private LogService logService;
public AuditLogAspect() {
log.info("AuditLogAspect is init");
}
/**
* 后置通知,当方法正常运行后触发
*
* @param joinPoint
* @param auditLog 审计日志
* @param result
*/
@AfterReturning(pointcut = "@annotation(auditLog)", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, AuditLog auditLog, Object result) {
doPrintLog(joinPoint, auditLog, result);
}
/**
* 方法抛出异常后通知
*
* @param joinPoint
* @param auditLog
* @param throwable
*/
@AfterThrowing(value = "@annotation(auditLog)", throwing = "throwable")
public void AfterThrowing(JoinPoint joinPoint, AuditLog auditLog, Throwable throwable) {
doPrintLog(joinPoint, auditLog, throwable.getMessage());
}
/**
* 打印安全日志
*
* @param joinPoint
* @param auditLog
* @param result
*/
private void doPrintLog(JoinPoint joinPoint, AuditLog auditLog, Object result) {
try {
String approveUser = getUser();
String ip = getHttpIp();
Object[] args = joinPoint.getArgs();
String methodName = joinPoint.getTarget().getClass().getName()
+ "."
+ joinPoint.getSignature().getName();
String tag = auditLog.tag() == null ? "" : auditLog.tag();
String operation = auditLog.operation();
operation = StringUtils.isEmpty(operation) ? auditLog.value() : operation;
operation = StringUtils.isEmpty(operation) ? methodName : operation;
String resultString = JsonUtils.toJSONString(result);
if (resultString != null && resultString.length() > RESULT_MAX_LENGTH) {
resultString = resultString.substring(0, RESULT_MAX_LENGTH);
}
logService.writeAuditLog(LcpAuditLog
.builder()
.ip(ip)
.operateTime(new Date())
.operator(approveUser)
.operation(operation)
.params(generateParamDigest(args))
.response(resultString)
.tag(tag)
.build());
} catch (Throwable t) {
log.error("AuditLogAspect 打印审计日志失败,失败原因:", t);
}
}
private String getHttpIp() {
try {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
return request.getRemoteAddr();
} catch (Exception e) {
//jsf调用时没有http上游ip,记录jsf ip 没有意义
return "";
}
}
/**
* 获取用户PIN
*
* @return
*/
private String getUser() {
// ...
}
/**
* 生成参数摘要字符串
*
* @since 1.1.12
*/
private String generateParamDigest(Object[] args) {
StringBuffer argSb = new StringBuffer();
for (Object arg : args) {
if (!(arg instanceof HttpServletRequest)) {
if (argSb.length() > 0) {
argSb.append(",");
}
String argString = JsonUtils.toJSONString(arg);
//避免超大参数
if (argString != null && argString.length() > PARAM_MAX_LENGTH) {
argString = argString.substring(0, PARAM_MAX_LENGTH);
}
argSb.append(argString);
}
}
return argSb.toString();
}
}
定义操作API
有了注解还需要API?
- 注解可以解决大部分情况,但是个别场景需要定制化记录
- 注解的解析结果也需要业务实现,代码层面业务解耦
service
public interface LogService {
/**
* 输出审计日志
*
* <pre>
* ex:
* writeAuditLog(LcpAuditLog
* .builder()
* .operation(operation)
* .operator(operator)
* .operateTime(new Date())
* .ip(getLocalHost())
* .influenceData(influenceData)
* .build());
* </pre>
*
* @param log
*/
void writeAuditLog(LcpAuditLog log);
/**
* 记录操作日志
* <pre>
* ex1:recordOperationLog("刁德三","删除用户","{userId:12,userName:lao sh an}");
* ex2:recordOperationLog("di da","deleteUser","{userId:12,userName:lao sh an}");
* </pre>
*
* @param operator 操作人
* @param operation 动作
* @param influenceData 影响数据
*/
void recordOperationLog(String operator, String operation, String influenceData);
/**
* 记录操作日志
* <pre>
* ex:recordOperationLog("刁德三","删除用户","{userId:12,userName:lao sh an}","运维操作");
* </pre>
*
* @param operator 操作人
* @param operation 动作
* @param influenceData 影响数据
* @param tag 标签
*/
void recordOperationLog(String operator, String operation, String influenceData, String tag);
}
业务实现ServiceImpl
@Slf4j
@Service("logService")
public class LogServiceImpl implements LogService {
@Override
public void writeAuditLog(LcpAuditLog lcpAuditLog) {
try {
if (log.isInfoEnabled()) {
log.info(JsonUtils.toJSONString(lcpAuditLog));
}
} catch (Throwable e) {
//借助外部输出异常log,因为当前类的log被特殊 监控!!
PrintLogUtil.printErrorLog("LcpLogServiceImpl 打印审计日志失败,e=", e);
}
}
@Override
public void recordOperationLog(String operator, String operation, String influenceData) {
this.writeAuditLog(LcpAuditLog
.builder()
.operation(operation)
.operator(operator)
.operateTime(new Date())
.ip(getLocalHost())
.influenceData(influenceData)
.build());
}
@Override
public void recordOperationLog(String operator, String operation, String influenceData, String tag) {
this.writeAuditLog(LcpAuditLog
.builder()
.operation(operation)
.operator(operator)
.operateTime(new Date())
.ip(getLocalHost())
.influenceData(influenceData)
.tag(tag)
.build());
}
private static String getLocalHost() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (Exception e) {
PrintLogUtil.printErrorLog("LcpLogServiceImpl 打印审计日志失败,e=", e);
return "";
}
}
}
业务代码只是一句log.info()???
kafka呢?
sl4j配置及kafka写入
sl4f2.0有封装对kafka的写入能力,具体实现:
引入必要的pom
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
log配置
sl4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<properties>
<property name="LOG_HOME">/data/Logs/common</property>
<property name="FILE_NAME">audit</property>
</properties>
<Appenders>
<RollingFile name="asyncRollingFile" fileName="${LOG_HOME}/${FILE_NAME}.log"
filePattern="${LOG_HOME}/$${date:yyyy-MM}/${FILE_NAME}-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="%-d{yyyy-MM-dd HH:mm:ss}[ %t:%r ] [%X{traceId}] - [%-5p] %c-%M:%L - %m%n%throwable{full}"/>
<Policies>
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="100 MB"/>
</Policies>
<DefaultRolloverStrategy max="20"/>
</RollingFile>
<Kafka name="auditLog" topic="log_jmq" syncSend="false">
<PatternLayout pattern="%m%n"/>
<Property name="client.id">client.id</Property>
<Property name="retries">3</Property>
<Property name="linger.ms">1000</Property>
<Property name="bootstrap.servers">nameserver:port</Property>
<Property name="compression.type">gzip</Property>
</Kafka>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] [%X{requestId}] %-5level %l - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="org.apache.kafka" level="info" />
<!--操作日志-->
<AsyncLogger name="com.service.impl.LogServiceImpl" level="INFO" additivity="false">
<AppenderRef ref="auditLog"/>
<AppenderRef ref="asyncRollingFile"/>
<AppenderRef ref="Console"/>
</AsyncLogger>
</Loggers>
</Configuration>
至此,可以完成对审计日志的log输出和mq写入,后续的mq消费,写入es就省掉了(因为是封装好的功能模块)