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:
2026-06-18 22:48:39 +08:00
parent cb9968ee76
commit 69425325f8
9 changed files with 364 additions and 0 deletions

View File

@@ -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{}
*/

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
}
/**
* 创建令牌
*

View File

@@ -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);
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;
}