審計日志實現
目標
記錄用戶行為:
- 用戶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就省掉了(因為是封裝好的功能模塊)