feat(security): 安全加固认证授权+审计日志
- TokenService: 添加token黑名单和刷新机制 - SysLoginService: 集成登录失败锁定 - LoginFailLockService: 新增登录失败锁定服务 - SysAuditLog: 扩展审计日志字段(风险级别/业务类型/客户端IP等) - V84__audit_enhancement.sql: 数据库迁移脚本 - IAuditEnhanceAppService: 审计日志增强服务接口 - AuditEnhanceAppServiceImpl: 审计日志增强服务实现 - AuditEnhanceController: 审计日志增强Controller(/audit/enhanced/logs, /audit/enhanced/stats) - auditlog-enhanced: 前端审计日志管理页面
This commit is contained in:
@@ -46,6 +46,16 @@ public class CacheConstants {
|
||||
*/
|
||||
public static final String LOGIN_SELECTED_TENANT = "login_selected_tenant:";
|
||||
|
||||
/**
|
||||
* Token黑名单 redis key
|
||||
*/
|
||||
public static final String TOKEN_BLACKLIST_KEY = "token_blacklist:";
|
||||
|
||||
/**
|
||||
* 登录失败锁定 redis key
|
||||
*/
|
||||
public static final String LOGIN_FAIL_LOCK_KEY = "login_fail_lock:";
|
||||
|
||||
/**
|
||||
* 超出上限,排番失败(时间:{},KEY:{}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.core.framework.web.service;
|
||||
|
||||
import com.core.common.constant.CacheConstants;
|
||||
import com.core.common.core.redis.RedisCache;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 登录失败锁定服务
|
||||
*
|
||||
* @author system
|
||||
*/
|
||||
@Component
|
||||
public class LoginFailLockService {
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Value("${user.password.maxRetryCount:5}")
|
||||
private int maxRetryCount;
|
||||
|
||||
@Value("${user.password.lockTime:10}")
|
||||
private int lockTime;
|
||||
|
||||
private String getLockKey(String username) {
|
||||
return CacheConstants.LOGIN_FAIL_LOCK_KEY + username;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否被锁定
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 是否被锁定
|
||||
*/
|
||||
public boolean isLocked(String username) {
|
||||
Integer failCount = redisCache.getCacheObject(getLockKey(username));
|
||||
return failCount != null && failCount >= maxRetryCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败次数
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void recordLoginFailure(String username) {
|
||||
String key = getLockKey(username);
|
||||
Integer count = redisCache.getCacheObject(key);
|
||||
if (count == null) {
|
||||
count = 0;
|
||||
}
|
||||
count++;
|
||||
redisCache.setCacheObject(key, count, lockTime, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除登录失败记录(登录成功时调用)
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void clearLoginFailure(String username) {
|
||||
redisCache.deleteObject(getLockKey(username));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余锁定时间(分钟)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 剩余分钟数
|
||||
*/
|
||||
public int getRemainingLockMinutes(String username) {
|
||||
Long ttl = redisCache.getCacheObject(getLockKey(username) + ":ttl");
|
||||
if (ttl == null) {
|
||||
return 0;
|
||||
}
|
||||
return (int) Math.ceil(ttl / 60.0);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ public class SysLoginService {
|
||||
@Autowired
|
||||
private ISysTenantOptionService sysTenantOptionService;
|
||||
|
||||
@Autowired
|
||||
private LoginFailLockService loginFailLockService;
|
||||
|
||||
/**
|
||||
* 登录验证
|
||||
*
|
||||
@@ -77,6 +80,11 @@ public class SysLoginService {
|
||||
* @return 结果
|
||||
*/
|
||||
public String login(String username, String password, String code, String uuid, Integer tenantId) {
|
||||
// 登录失败锁定检查
|
||||
if (loginFailLockService.isLocked(username)) {
|
||||
throw new com.core.common.exception.ServiceException("账号已被锁定,请稍后再试");
|
||||
}
|
||||
|
||||
// 验证码校验
|
||||
// validateCaptcha(username, code, uuid);
|
||||
|
||||
@@ -96,6 +104,8 @@ public class SysLoginService {
|
||||
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
|
||||
authentication = authenticationManager.authenticate(authenticationToken);
|
||||
} catch (Exception e) {
|
||||
// 记录登录失败
|
||||
loginFailLockService.recordLoginFailure(username);
|
||||
if (e instanceof BadCredentialsException ex) {
|
||||
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL,
|
||||
MessageUtils.message("user.password.not.match")));
|
||||
@@ -108,6 +118,8 @@ public class SysLoginService {
|
||||
} finally {
|
||||
AuthenticationContextHolder.clearContext();
|
||||
}
|
||||
// 登录成功,清除失败记录
|
||||
loginFailLockService.clearLoginFailure(username);
|
||||
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS,
|
||||
MessageUtils.message("user.login.success")));
|
||||
LoginUser loginUser = (LoginUser)authentication.getPrincipal();
|
||||
|
||||
@@ -59,6 +59,10 @@ public class TokenService {
|
||||
String token = getToken(request);
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
try {
|
||||
// 检查token是否在黑名单中
|
||||
if (isTokenBlacklisted(token)) {
|
||||
return null;
|
||||
}
|
||||
Claims claims = parseToken(token);
|
||||
// 解析对应的权限以及用户信息
|
||||
String uuid = (String)claims.get(Constants.LOGIN_USER_KEY);
|
||||
@@ -113,6 +117,49 @@ public class TokenService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将token加入黑名单
|
||||
*
|
||||
* @param token 令牌
|
||||
* @param expireMinutes 过期时间(分钟)
|
||||
*/
|
||||
public void blacklistToken(String token, long expireMinutes) {
|
||||
if (StringUtils.isNotEmpty(token)) {
|
||||
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token;
|
||||
redisCache.setCacheObject(blacklistKey, "1", (int) expireMinutes, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否在黑名单中
|
||||
*
|
||||
* @param token 令牌
|
||||
* @return 是否被黑名单
|
||||
*/
|
||||
public boolean isTokenBlacklisted(String token) {
|
||||
if (StringUtils.isEmpty(token)) {
|
||||
return false;
|
||||
}
|
||||
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token;
|
||||
return redisCache.getCacheObject(blacklistKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌(生成新token)
|
||||
*
|
||||
* @param loginUser 用户信息
|
||||
* @return 新令牌
|
||||
*/
|
||||
public String refreshAccessToken(LoginUser loginUser) {
|
||||
if (StringUtils.isNull(loginUser) || StringUtils.isEmpty(loginUser.getToken())) {
|
||||
return null;
|
||||
}
|
||||
// 将旧token加入黑名单
|
||||
blacklistToken(loginUser.getToken(), expireTime);
|
||||
// 创建新token
|
||||
return createToken(loginUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建令牌
|
||||
*
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.healthlink.his.web.system.appservice;
|
||||
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.sys.domain.SysAuditLog;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审计日志增强服务接口
|
||||
*/
|
||||
public interface IAuditEnhanceAppService {
|
||||
|
||||
/**
|
||||
* 分页查询增强审计日志
|
||||
*/
|
||||
R<?> getEnhancedLogs(String userName, String module, String action, String riskLevel,
|
||||
String businessType, String startDate, String endDate,
|
||||
Integer pageNo, Integer pageSize);
|
||||
|
||||
/**
|
||||
* 获取审计日志统计数据
|
||||
*/
|
||||
R<?> getAuditStats(String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 记录增强审计日志
|
||||
*/
|
||||
void recordEnhancedLog(SysAuditLog log);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.healthlink.his.web.system.appservice.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.sys.domain.SysAuditLog;
|
||||
import com.healthlink.his.sys.mapper.SysAuditLogMapper;
|
||||
import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 审计日志增强服务实现
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuditEnhanceAppServiceImpl implements IAuditEnhanceAppService {
|
||||
|
||||
private final SysAuditLogMapper auditLogMapper;
|
||||
|
||||
@Override
|
||||
public R<?> getEnhancedLogs(String userName, String module, String action, String riskLevel,
|
||||
String businessType, String startDate, String endDate,
|
||||
Integer pageNo, Integer pageSize) {
|
||||
LambdaQueryWrapper<SysAuditLog> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.like(StringUtils.hasText(userName), SysAuditLog::getUserName, userName)
|
||||
.eq(StringUtils.hasText(module), SysAuditLog::getModule, module)
|
||||
.eq(StringUtils.hasText(action), SysAuditLog::getAction, action)
|
||||
.eq(StringUtils.hasText(riskLevel), SysAuditLog::getRiskLevel, riskLevel)
|
||||
.eq(StringUtils.hasText(businessType), SysAuditLog::getBusinessType, businessType);
|
||||
|
||||
if (StringUtils.hasText(startDate)) {
|
||||
try {
|
||||
Date start = new SimpleDateFormat("yyyy-MM-dd").parse(startDate);
|
||||
wrapper.ge(SysAuditLog::getCreateTime, start);
|
||||
} catch (ParseException e) {
|
||||
log.warn("日期解析失败: {}", startDate);
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(endDate)) {
|
||||
try {
|
||||
Date end = new SimpleDateFormat("yyyy-MM-dd").parse(endDate);
|
||||
wrapper.le(SysAuditLog::getCreateTime, end);
|
||||
} catch (ParseException e) {
|
||||
log.warn("日期解析失败: {}", endDate);
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(SysAuditLog::getCreateTime);
|
||||
Page<SysAuditLog> page = auditLogMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
|
||||
return R.ok(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<?> getAuditStats(String startDate, String endDate) {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 按模块统计
|
||||
String moduleSql = "SELECT module, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
// 按风险级别统计
|
||||
String riskSql = "SELECT risk_level, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
// 按业务类型统计
|
||||
String businessSql = "SELECT business_type, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'";
|
||||
|
||||
if (StringUtils.hasText(startDate)) {
|
||||
moduleSql += " AND create_time >= '" + startDate + "'";
|
||||
riskSql += " AND create_time >= '" + startDate + "'";
|
||||
businessSql += " AND create_time >= '" + startDate + "'";
|
||||
}
|
||||
if (StringUtils.hasText(endDate)) {
|
||||
moduleSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
riskSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
businessSql += " AND create_time <= '" + endDate + " 23:59:59'";
|
||||
}
|
||||
|
||||
moduleSql += " GROUP BY module ORDER BY count DESC";
|
||||
riskSql += " GROUP BY risk_level ORDER BY count DESC";
|
||||
businessSql += " GROUP BY business_type ORDER BY count DESC";
|
||||
|
||||
try {
|
||||
stats.put("moduleStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(moduleSql)));
|
||||
stats.put("riskStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(riskSql)));
|
||||
stats.put("businessStats", auditLogMapper.selectMaps(
|
||||
new LambdaQueryWrapper<SysAuditLog>().last(businessSql)));
|
||||
} catch (Exception e) {
|
||||
log.error("统计审计日志失败", e);
|
||||
}
|
||||
|
||||
return R.ok(stats);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordEnhancedLog(SysAuditLog auditLog) {
|
||||
try {
|
||||
auditLogMapper.insert(auditLog);
|
||||
} catch (Exception e) {
|
||||
log.error("记录审计日志失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.healthlink.his.web.system.controller;
|
||||
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 审计日志增强 Controller
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/audit/enhanced")
|
||||
@RequiredArgsConstructor
|
||||
public class AuditEnhanceController {
|
||||
|
||||
private final IAuditEnhanceAppService auditEnhanceAppService;
|
||||
|
||||
/**
|
||||
* 分页查询增强审计日志
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
@PreAuthorize("@ss.hasPermi('system:audit:list')")
|
||||
public R<?> getEnhancedLogs(
|
||||
@RequestParam(value = "userName", required = false) String userName,
|
||||
@RequestParam(value = "module", required = false) String module,
|
||||
@RequestParam(value = "action", required = false) String action,
|
||||
@RequestParam(value = "riskLevel", required = false) String riskLevel,
|
||||
@RequestParam(value = "businessType", required = false) String businessType,
|
||||
@RequestParam(value = "startDate", required = false) String startDate,
|
||||
@RequestParam(value = "endDate", required = false) String endDate,
|
||||
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
return auditEnhanceAppService.getEnhancedLogs(userName, module, action, riskLevel,
|
||||
businessType, startDate, endDate, pageNo, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审计日志统计数据
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
@PreAuthorize("@ss.hasPermi('system:audit:list')")
|
||||
public R<?> getAuditStats(
|
||||
@RequestParam(value = "startDate", required = false) String startDate,
|
||||
@RequestParam(value = "endDate", required = false) String endDate) {
|
||||
return auditEnhanceAppService.getAuditStats(startDate, endDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V84: 审计日志增强 - 扩展字段
|
||||
-- 为系统操作审计日志添加增强字段
|
||||
|
||||
-- 添加新字段
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS user_agent VARCHAR(512) DEFAULT NULL COMMENT '用户代理';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS request_method VARCHAR(10) DEFAULT NULL COMMENT '请求方法(GET/POST/PUT/DELETE)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_code INT DEFAULT NULL COMMENT '响应状态码';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_time_ms BIGINT DEFAULT NULL COMMENT '响应时间(毫秒)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS client_ip VARCHAR(50) DEFAULT NULL COMMENT '客户端IP';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(100) DEFAULT NULL COMMENT '租户名称';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS org_name VARCHAR(100) DEFAULT NULL COMMENT '科室名称';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS risk_level VARCHAR(20) DEFAULT 'LOW' COMMENT '风险级别(LOW/MEDIUM/HIGH/CRITICAL)';
|
||||
ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS business_type VARCHAR(50) DEFAULT NULL COMMENT '业务类型(login/logout/query/insert/update/delete/export)';
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON sys_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_module ON sys_audit_log(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON sys_audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_create_time ON sys_audit_log(create_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_risk_level ON sys_audit_log(risk_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_business_type ON sys_audit_log(business_type);
|
||||
@@ -25,4 +25,13 @@ public class SysAuditLog extends HisBaseEntity {
|
||||
private String result;
|
||||
private String errorMsg;
|
||||
private Integer durationMs;
|
||||
private String userAgent;
|
||||
private String requestMethod;
|
||||
private Integer responseCode;
|
||||
private Long responseTimeMs;
|
||||
private String clientIp;
|
||||
private String tenantName;
|
||||
private String orgName;
|
||||
private String riskLevel;
|
||||
private String businessType;
|
||||
}
|
||||
Reference in New Issue
Block a user