1、背景介绍
软件系统开发中,信息安全至关重要,特别是对于新手开发者。随着经验的积累,开发者应从满足基本业务需求的"能用",进一步关注系统的"可靠性"和"安全性"。大型公司尤其重视信息安全,因为小漏洞可能造成巨大损失。
电商和支付系统在需求旺季易受黑客攻击,导致服务中断,影响用户体验。一些公司甚至支付赎金以求息事宁人,但这并非长久之计。关键还是要提升系统的安全防御能力。
信息安全包含多个方面,这里重点介绍审计日志的重要性。审计日志记录了谁、在什么时间、对什么数据、进行了哪些操作,对于事后问题追踪和系统审查至关重要。任何IT系统在审核时,审计日志都是必审项。
简而言之,信息安全是软件开发中不可忽视的重要方面,审计日志作为其中的关键组成部分,对于保障系统的安全性和可追溯性具有重要作用。开发者应提高安全意识,从多个维度加强系统的安全防护。
2、方案实践
实现【审计日志】这个需求,我们有一个很好的技术解决方案,就是使用 Spring 的切面编程,创建一个代理类,利用afterReturning
和afterThrowing
方法来实现日志的记录。
具体实现步骤如下:
创建完springboot项目之后加一些依赖:
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20220320</version> <!-- 请使用最新的版本号 -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
2.1 创建审计日志表
CREATE TABLE `tb_audit_log` (
`id` bigint(20) NOT NULL COMMENT '审计日志,主键ID',
`table_name` varchar(500) DEFAULT '' COMMENT '操作的表名,多个用逗号隔开',
`operate_desc` varchar(200) DEFAULT '' COMMENT '操作描述',
`request_param` varchar(200) DEFAULT '' COMMENT '请求参数',
`result` int(10) COMMENT '执行结果,0:成功,1:失败',
`ex_msg` varchar(200) DEFAULT '' COMMENT '异常信息',
`user_agent` text COLLATE utf8mb4_unicode_ci COMMENT '用户代理信息',
`ip_address` varchar(32) NOT NULL DEFAULT '' COMMENT '操作时设备IP',
`ip_address_name` varchar(32) DEFAULT '' COMMENT '操作时设备IP所在地址',
`operate_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`operate_user_id` varchar(32) DEFAULT '' COMMENT '操作人ID',
`operate_user_name` varchar(32) DEFAULT '' COMMENT '操作人',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审计日志表';
2.2 编写一个注解类
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
@Documented
public @interface SystemAuditLog {
/**
* 操作了的表名
* @return
*/
String tableName() default "";
/**
* 日志描述
* @return
*/
String description() default "";
}
2.3 编写获取ip地址以及所在地工具类
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.HttpURLConnection;
import org.json.JSONObject;
public class IpAddressUtil {
/**
* 从HttpServletRequest中获取IP地址,处理了X-Forwarded-For和Proxy-Client-IP
*
* @param request HttpServletRequest对象
* @return IP地址字符串
*/
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* 根据IP地址查询所在地,使用第三方服务ip-api.com
*
* @param ipAddress IP地址字符串
* @return IP所在地信息的JSON字符串
*/
public static String getLoginAddress(String ipAddress) {
String url = "http://ip-api.com/json/" + ipAddress;
StringBuilder response = new StringBuilder();
try {
URL urlObj = new URL(url);
HttpURLConnection con = (HttpURLConnection) urlObj.openConnection();
con.setRequestMethod("GET");
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
} catch (Exception e) {
e.printStackTrace();
}
return response.toString();
}
/**
* 从第三方服务的响应中提取IP所在地信息
*
* @param ipApiResponse 来自ip-api.com的响应字符串
* @return 格式化的IP所在地信息
*/
public static String formatLocation(String ipApiResponse) {
JSONObject jsonObject = new JSONObject(ipApiResponse);
String city = jsonObject.optString("city", "未知城市");
String regionName = jsonObject.optString("regionName", "未知地区");
String country = jsonObject.optString("country", "未知国家");
return "IP所在地: " + country + ", " + regionName + ", " + city;
}
// 测试IpAddressUtil工具类
public static void main(String[] args) {
// 模拟HttpServletRequest,实际使用时将由Servlet容器提供
// HttpServletRequest request = ...;
// String ipAddress = getIpAddress(request);
// String locationJson = getLoginAddress(ipAddress);
// String location = formatLocation(locationJson);
// System.out.println("IP地址: " + ipAddress);
// System.out.println("IP所在地: " + location);
}
}
2.4 编写一个代理类
import com.alibaba.fastjson.JSON;
import com.xiaobai.easyexceldemo.annotation.SystemAuditLog;
import com.xiaobai.easyexceldemo.domain.AuditLog;
import com.xiaobai.easyexceldemo.domain.BaseRequest;
import com.xiaobai.easyexceldemo.service.AuditLogService;
import com.xiaobai.easyexceldemo.utils.IpAddressUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.Objects;
/**
* @author: xiaobai
* @Desc:
* @create: 2024-08-28
**/
@Component
@Aspect
public class SystemAuditLogAspect {
@Autowired
private AuditLogService systemAuditLogService;
/**
* 定义切入点,切入所有标注此注解的类和方法
*/
@Pointcut("@within(com.xiaobai.easyexceldemo.annotation.SystemAuditLog)||@annotation(com.xiaobai.easyexceldemo.annotation.SystemAuditLog)")
public void methodAspect() {
}
/**
* 方法调用前拦截
*/
@Before("methodAspect()")
public void before() {
System.out.println("SystemAuditLog代理 -> 调用方法执行之前......");
}
/**
* 方法调用后拦截
*/
@After("methodAspect()")
public void after() {
System.out.println("SystemAuditLog代理 -> 调用方法执行之后......");
}
/**
* 调用方法结束拦截
*/
@AfterReturning(value = "methodAspect()")
public void afterReturning(JoinPoint joinPoint) throws Exception {
System.out.println("SystemAuditLog代理 -> 调用方法结束拦截......");
//封装数据
AuditLog entity = warpAuditLog(joinPoint);
entity.setResult(0);
//插入到数据库
systemAuditLogService.save(entity);
}
/**
* 抛出异常拦截
*/
@AfterThrowing(value = "methodAspect()", throwing = "ex")
public void afterThrowing(JoinPoint joinPoint, Exception ex) throws Exception {
System.out.println("SystemAuditLog代理 -> 抛出异常拦截......");
//封装数据
AuditLog entity = warpAuditLog(joinPoint);
entity.setResult(1);
//封装错误信息
entity.setExMsg(ex.getMessage());
//插入到数据库
systemAuditLogService.save(entity);
}
/**
* 封装插入实体
*
* @param joinPoint
* @return
* @throws Exception
*/
private AuditLog warpAuditLog(JoinPoint joinPoint) throws Exception {
//获取请求上下文
HttpServletRequest request = getHttpServletRequest();
//获取注解上的参数值
SystemAuditLog systemAuditLog = getServiceMethodDescription(joinPoint);
//获取请求参数
Object requestObj = getServiceMethodParams(joinPoint);
//封装数据
AuditLog auditLog = new AuditLog();
//从请求上下文对象获取相应的数据
auditLog.setUserAgent(request.getHeader("User-Agent"));
//获取登录时的ip地址
auditLog.setIpAddress(IpAddressUtil.getIpAddress(request));
//调用外部接口,获取IP所在地
auditLog.setIpAddressName(IpAddressUtil.formatLocation(IpAddressUtil.getLoginAddress(auditLog.getIpAddress())));
//封装操作的表和描述
if (Objects.nonNull(systemAuditLog)) {
auditLog.setTableName(systemAuditLog.tableName());
auditLog.setOperateDesc(systemAuditLog.description());
}
//封装请求参数
auditLog.setRequestParam(JSON.toJSONString(requestObj));
//封装请求人
if (Objects.nonNull(requestObj) && requestObj instanceof BaseRequest) {
auditLog.setOperateUserId(((BaseRequest) requestObj).getLoginUserId());
auditLog.setOperateUserName(((BaseRequest) requestObj).getLoginUserName());
}
auditLog.setOperateTime(new Date());
return auditLog;
}
/**
* 获取当前的request
* 这里如果报空指针异常是因为单独使用spring获取request
* 需要在配置文件里添加监听
* <p>
* 如果是spring项目,通过下面方式注入
* <listener>
* <listener-class>
* org.springframework.web.context.request.RequestContextListener
* </listener-class>
* </listener>
* <p>
* 如果是springboot项目,在配置类里面,通过下面方式注入
*
* @return
* @Bean public RequestContextListener requestContextListener(){
* return new RequestContextListener();
* }
*/
private HttpServletRequest getHttpServletRequest() {
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
return sra.getRequest();
}
/**
* 获取请求对象
*
* @param joinPoint
* @return
* @throws Exception
*/
private Object getServiceMethodParams(JoinPoint joinPoint) {
Object[] arguments = joinPoint.getArgs();
if (Objects.nonNull(arguments) && arguments.length > 0) {
return arguments[0];
}
return null;
}
/**
* 获取自定义注解里的参数
*
* @param joinPoint
* @return 返回注解里面的日志描述
* @throws Exception
*/
private SystemAuditLog getServiceMethodDescription(JoinPoint joinPoint) throws Exception {
//类名
String targetName = joinPoint.getTarget().getClass().getName();
//方法名
String methodName = joinPoint.getSignature().getName();
//参数
Object[] arguments = joinPoint.getArgs();
//通过反射获取示例对象
Class targetClass = Class.forName(targetName);
//通过实例对象方法数组
Method[] methods = targetClass.getMethods();
for (Method method : methods) {
//判断方法名是不是一样
if (method.getName().equals(methodName)) {
//对比参数数组的长度
Class[] clazzs = method.getParameterTypes();
if (clazzs.length == arguments.length) {
//获取注解里的日志信息
return method.getAnnotation(SystemAuditLog.class);
}
}
}
return null;
}
}
2.5 最后只需要加注解
@RestController
@RequestMapping("api")
public class LoginController {
/**
* 用户登录,添加审计日志注解
* @param request
*/
@SystemAuditLog(tableName = "tb_user", description = "用户登录")
@PostMapping("login")
public void login(UserLoginDTO request){
//登录逻辑处理
}
}
备注:相关的实体类
@Data
public class AuditLog {
/**
* 审计日志,主键ID
*/
private Long id;
/**
* 操作的表名,多个用逗号隔开
*/
private String tableName;
/**
* 操作描述
*/
private String operateDesc;
/**
* 请求参数
*/
private String requestParam;
/**
* 执行结果,0:成功,1:失败
*/
private Integer result;
/**
* 异常信息
*/
private String exMsg;
/**
* 请求代理信息
*/
private String userAgent;
/**
* 操作时设备IP
*/
private String ipAddress;
/**
* 操作时设备IP所在地址
*/
private String ipAddressName;
/**
* 操作时间
*/
private Date operateTime;
/**
* 操作人ID
*/
private String operateUserId;
/**
* 操作人
*/
private String operateUserName;
}
public class BaseRequest implements Serializable {
/**
* 请求token
*/
private String token;
/**
* 登录人ID
*/
private String loginUserId;
/**
* 登录人姓名
*/
private String loginUserName;
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getLoginUserId() {
return loginUserId;
}
public void setLoginUserId(String loginUserId) {
this.loginUserId = loginUserId;
}
public String getLoginUserName() {
return loginUserName;
}
public void setLoginUserName(String loginUserName) {
this.loginUserName = loginUserName;
}
}
@Data
public class UserLoginDTO extends BaseRequest {
/**
* 用户名
*/
private String userName;
/**
* 密码
*/
private String password;
}
至此,你就完成了审计日志的核心代码编写,可以测试一下你的成果,调用一下登录的api,然后去数据库,会有你想要的结果,接下来就是根据自己的业务去修改你的代码,记录想要记录的日志。若有更好的方案,麻烦评论区告知一下,我去学习一番,共勉!